fix: container acting as a branch marks unselected exposed outputs NOT_SELECTED
A GenericContainer whose subflow takes only one of several internal branches left the other branches' exposed outputs unproduced. The completion path (reconcileGenericSubflow / GenericContainerExecutor.completedResult) returned NodeExecutionResult.completed(valueMap) with an empty notSelectedOutputs, so ExecutionContext.completeStep marked every unproduced exposed output UNAVAILABLE. Downstream that reads as an error signal, not a skip: a BranchRejoin (which self-skips only when ALL inputs are NOT_SELECTED) instead saw UNAVAILABLE, went READY and threw BRANCH_REJOIN_INPUT_UNAVAILABLE, failing the whole execution. A plain routing block never hits this because it reports its not-taken outputs via notSelectedOutputs. Both container completion paths now return NodeExecutionResult.routed(values, allExposedOutputNames): a successful subflow that didn't take an internal branch surfaces that branch's exposed output as NOT_SELECTED, so downstream steps and BranchRejoins skip cleanly - letting a container act as a real branching node (early-stop outcomes exposed as outputs routed to top-level EndBlocks). Containers with a single always-produced output are unaffected (that output is in the value map, notSelected is empty). Found while building the containerized Jensen recruitment flow, where phase containers expose early-stop outputs; added a focused regression test (genericContainerUnselectedExposedOutputsSkipDownstreamCleanlyInsteadOfErroring) and the "Jensen Recruitment Process - Full Revised (Containerized)" seed flow that groups the 12 swimlanes into 7 phase containers (13 top-level nodes instead of 39). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1c94cd48d6
commit
4f0f52c4f1
|
|
@ -686,9 +686,16 @@ public class ExecutionsService {
|
|||
GenericContainerConfiguration configuration, ExecutionObject child) {
|
||||
if (child.getContext().getStatus() == ExecutionStatus.SUCCESS) {
|
||||
parent.setExecutionVariableDescriptors(child.getContext().getExecutionVariableDescriptors());
|
||||
List<ContainerFlowInterfaceResolver.ExposedHandle> exposedOutputs =
|
||||
ContainerFlowInterfaceResolver.getExposedOutputs(configuration.getSubFlow());
|
||||
// routed(), not completed(): a successful subflow that did not take an internal
|
||||
// branch leaves that branch's exposed output unproduced. Marking it NOT_SELECTED
|
||||
// (rather than the default UNAVAILABLE that completed() would cause downstream)
|
||||
// lets the container act as a branching node - downstream steps and BranchRejoins
|
||||
// can skip cleanly instead of erroring.
|
||||
parent.completeContainerSubflow(parentStep.getId(),
|
||||
NodeExecutionResult.completed(collectExposedOutputsAsMap(child,
|
||||
ContainerFlowInterfaceResolver.getExposedOutputs(configuration.getSubFlow()))));
|
||||
NodeExecutionResult.routed(collectExposedOutputsAsMap(child, exposedOutputs),
|
||||
exposedOutputPublicNames(exposedOutputs)));
|
||||
return;
|
||||
}
|
||||
parent.failContainerSubflow(parentStep.getId(),
|
||||
|
|
@ -1180,6 +1187,13 @@ public class ExecutionsService {
|
|||
return outputs;
|
||||
}
|
||||
|
||||
private static Set<String> exposedOutputPublicNames(
|
||||
List<ContainerFlowInterfaceResolver.ExposedHandle> exposedOutputs) {
|
||||
return exposedOutputs.stream()
|
||||
.map(ContainerFlowInterfaceResolver.ExposedHandle::publicName)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private static ExecutionVariableKind mapExecutionVariableKind(IODescriptor descriptor) {
|
||||
return switch (descriptor.getType()) {
|
||||
case TEXT -> ExecutionVariableKind.TEXT;
|
||||
|
|
|
|||
|
|
@ -133,15 +133,20 @@ public class GenericContainerExecutor implements ContainerExecutor<GenericContai
|
|||
executionVariableDescriptors.putAll(innerExecution.getContext().getExecutionVariableDescriptors());
|
||||
|
||||
Map<String, Object> outputs = new java.util.LinkedHashMap<>();
|
||||
java.util.Set<String> exposedNames = new java.util.LinkedHashSet<>();
|
||||
for (ContainerFlowInterfaceResolver.ExposedHandle exposedHandle : ContainerFlowInterfaceResolver
|
||||
.getExposedOutputs(configuration.getSubFlow())) {
|
||||
ContainerFlowInterfaceResolver.OpenHandle handle = exposedHandle.handle();
|
||||
exposedNames.add(exposedHandle.publicName());
|
||||
Object value = innerExecution.getContext().getResult().get(new FieldKey(handle.blockId(), handle.io().getName()));
|
||||
if (value != null) {
|
||||
outputs.put(exposedHandle.publicName(), value);
|
||||
}
|
||||
}
|
||||
return NodeExecutionResult.completed(outputs);
|
||||
// routed(), not completed(): unproduced exposed outputs (internal branch not taken)
|
||||
// must be NOT_SELECTED so downstream steps/BranchRejoins skip cleanly instead of
|
||||
// receiving UNAVAILABLE and erroring. See ExecutionsService.reconcileGenericSubflow.
|
||||
return NodeExecutionResult.routed(outputs, exposedNames);
|
||||
}
|
||||
|
||||
private void propagateAuthorizations(ExecutionObject innerExecution, Map<String, Object> authorizations) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -173,6 +173,9 @@ public class ExecutionTest {
|
|||
@Autowired
|
||||
DelimitedParserBlockFactory delimitedParserBlockFactory;
|
||||
|
||||
@Autowired
|
||||
it.cnr.isti.workflow.manager.blocks.factories.BranchRejoinBlockFactory branchRejoinBlockFactory;
|
||||
|
||||
@Autowired
|
||||
HumanInteractiveBlockFactory humanInteractiveBlockFactory;
|
||||
|
||||
|
|
@ -1678,6 +1681,65 @@ public class ExecutionTest {
|
|||
&& event.getDetails().get("iterationIndex") != null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void genericContainerUnselectedExposedOutputsSkipDownstreamCleanlyInsteadOfErroring() {
|
||||
// A container whose internal Switch takes ONE of three branches. The two branches
|
||||
// not taken feed a top-level BranchRejoin. Before the fix the container marked those
|
||||
// unproduced exposed outputs UNAVAILABLE, so the BranchRejoin (which only self-skips on
|
||||
// all-NOT_SELECTED) errored the whole execution. They must now be NOT_SELECTED so the
|
||||
// rejoin and its downstream skip cleanly and the taken branch completes.
|
||||
Block<SwitchBlockType> router = switchBlockFactory.create(SwitchBlockConfiguration.builder()
|
||||
.name("Router")
|
||||
.cases(List.of(new SwitchCase("escape"), new SwitchCase("contA"), new SwitchCase("contB")))
|
||||
.condition("${{decision}}")
|
||||
.useLlm(false)
|
||||
.outputTemplate("Routed ${{candidate}}")
|
||||
.build());
|
||||
Container<GenericContainerType> gate = genericContainerFactory.create(GenericContainerConfiguration.builder()
|
||||
.name("Gate")
|
||||
.subFlow(FlowData.builder().block(router).build())
|
||||
.build());
|
||||
|
||||
Block<LLMBlockType> llmEscape = llmBlockFactory.create(LLMBlockConfiguration.builder()
|
||||
.name("Escape LLM").llmDescriptor(llmBrick).prompt("Hello, ${{escaped}}!").build());
|
||||
Block<it.cnr.isti.workflow.manager.blocks.types.BranchRejoinBlockType> rejoin = branchRejoinBlockFactory.create(
|
||||
it.cnr.isti.workflow.manager.blocks.configurations.BranchRejoinBlockConfiguration.builder()
|
||||
.name("rejoin")
|
||||
.inputs(List.of(
|
||||
new it.cnr.isti.workflow.manager.blocks.configurations.BranchRejoinInput("a"),
|
||||
new it.cnr.isti.workflow.manager.blocks.configurations.BranchRejoinInput("b")))
|
||||
.outputName("output")
|
||||
.build());
|
||||
Block<LLMBlockType> llmFinal = llmBlockFactory.create(LLMBlockConfiguration.builder()
|
||||
.name("Final LLM").llmDescriptor(llmBrick).prompt("Done ${{joined}}!").build());
|
||||
|
||||
FlowData flow = FlowData.builder()
|
||||
.container(gate)
|
||||
.block(llmEscape)
|
||||
.block(rejoin)
|
||||
.block(llmFinal)
|
||||
.connection(Connection.builder().sourceId(gate.getId()).sourceName("escape")
|
||||
.targetId(llmEscape.getId()).targetName("escaped").build())
|
||||
.connection(Connection.builder().sourceId(gate.getId()).sourceName("contA")
|
||||
.targetId(rejoin.getId()).targetName("a").build())
|
||||
.connection(Connection.builder().sourceId(gate.getId()).sourceName("contB")
|
||||
.targetId(rejoin.getId()).targetName("b").build())
|
||||
.connection(Connection.builder().sourceId(rejoin.getId()).sourceName("output")
|
||||
.targetId(llmFinal.getId()).targetName("joined").build())
|
||||
.build();
|
||||
|
||||
ExecutionObject execObject = executionsService.createExecution("Gate flow", flow);
|
||||
execObject = executionsService.prepareInput(execObject.getId(), gate.getId(), "decision", "escape");
|
||||
execObject = executionsService.prepareInput(execObject.getId(), gate.getId(), "candidate", "Alex");
|
||||
execObject = executionsService.startExecution(execObject.getId());
|
||||
execObject = awaitNonBlockingContainerCompletion(execObject);
|
||||
|
||||
assertEquals(ExecutionStatus.SUCCESS, execObject.getContext().getStatus());
|
||||
assertEquals(StepStatus.COMPLETED, execObject.getContext().getSteps().get(llmEscape.getId()).getStatus());
|
||||
assertEquals(StepStatus.SKIPPED, execObject.getContext().getSteps().get(rejoin.getId()).getStatus());
|
||||
assertEquals(StepStatus.SKIPPED, execObject.getContext().getSteps().get(llmFinal.getId()).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getContainerIterationsByOwnerListsOneChildPerIterationInOrder() {
|
||||
Block<LLMBlockType> internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder()
|
||||
|
|
|
|||
Loading…
Reference in New Issue