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:
Lucio Lelii 2026-07-24 21:41:52 +02:00
parent 1c94cd48d6
commit 4f0f52c4f1
4 changed files with 1994 additions and 3 deletions

View File

@ -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;

View File

@ -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

View File

@ -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()