From 4fa87ae3a2b0f13edf92c3d9a36b6bb76beeb2f3 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 14:53:09 +0200 Subject: [PATCH] Carry the parent's global inputs into an iterator or loop subflow A container subflow is its own execution, so the global inputs typed on the parent have to be handed to it. createAndStartSubflowChild took a "global." view of getExecutionVariables(), but that is the *unprefixed* variables map - the prefixed keys live in runtimeExecutionVariables, which is deliberately not exposed. The view therefore always matched nothing, and every child was created with null values for the globals its blocks reference. The child then never left CREATED, and the parent failed with Execution with id is not in READY status (CURRENT STATUS is CREATED) naming a child execution the user never sees and saying nothing about the cause. GenericContainer was unaffected: its executor has its own copy of this propagation that reads the runtime map, which is why only iterator and loop subflows were broken. The fix reads the parent's global inputs from the map that holds them. The test runs an iterator whose subflow prompt references ${{global.who}}, and fails with exactly the error above when the line is reverted. Co-Authored-By: Claude Opus 5 (1M context) --- .../manager/executions/ExecutionsService.java | 7 ++- .../manager/executions/ExecutionTest.java | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java index 0aef4a5..b753365 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java @@ -1060,7 +1060,12 @@ public class ExecutionsService { } } setExecutionVariableDescriptors(child.getId(), parent.getContext().getExecutionVariableDescriptors()); - Map globalInputs = ExecutionRuntimeContextSupport.globalView(parent.getContext().getExecutionVariables()); + // The parent's own global inputs, read from the map that holds them. This used to take a + // "global." view of getExecutionVariables(), which is the *unprefixed* variables map and so + // never matched the prefix: every child was handed null values, sat in CREATED and failed + // the parent with "is not in READY status". The prefixed keys live in the runtime map, + // which is deliberately not exposed - so go to the source instead. + Map globalInputs = new LinkedHashMap<>(parent.getContext().getGlobalInputs()); Map globalDescriptors = new LinkedHashMap<>(); for (IODescriptor required : child.getRequiredGlobalInputs()) { globalDescriptors.put(required.getName(), ExecutionVariableDescriptor.builder() diff --git a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java index d439087..072e626 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/executions/ExecutionTest.java @@ -1381,6 +1381,58 @@ public class ExecutionTest { && event.getDetails().get("innerExecutionId") != null)); } + @Test + public void iteratorSubflowInheritsTheGlobalInputsSetOnTheParent() { + // A subflow is its own execution, so the values typed on the parent have to be carried into + // it. When they are not, the child sits in CREATED and the parent fails with "is not in + // READY status", naming a child execution the user never saw and explaining nothing. + Block internalBlock = llmBlockFactory.create(LLMBlockConfiguration.builder() + .name("Internal LLM") + .llmDescriptor(llmBrick) + .prompt("Hello, ${{name}} from ${{global.who}}!") + .build()); + + Container container = iteratorContainerFactory.create(IteratorContainerConfiguration.builder() + .name("Iterator") + .subFlow(FlowData.builder() + .block(internalBlock) + .globalInput(IODescriptor.input("who", IOType.TEXT, false, null)) + .build()) + .iterationInput("name") + .build()); + + ExecutionObject execObject = executionsService.createExecution("Iterator flow", FlowData.builder() + .container(container) + .globalInput(IODescriptor.input("who", IOType.TEXT, false, null)) + .build()); + + // The parent collects what the subflow needs: it is the only place the user can supply it. + assertTrue(execObject.getRequiredGlobalInputs().stream() + .anyMatch(descriptor -> descriptor.getName().equals("who"))); + + executionsService.setGlobalInput(execObject.getId(), "who", "HR"); + executionsService.prepareInput(execObject.getId(), container.getId(), "name", List.of("Alice", "Bob")); + assertEquals(ExecutionStatus.READY, execObject.getContext().getStatus()); + + execObject = executionsService.startExecution(execObject.getId()); + // WAITING is transient here: the parent suspends between iterations while a child runs. + for (int attempt = 0; attempt < 200 && !execObject.getContext().getStatus().isFinalState(); attempt++) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + execObject = executionsService.getExecution(execObject.getId()); + } + + ExecutionObject finished = execObject; + assertEquals(ExecutionStatus.SUCCESS, finished.getContext().getStatus(), + () -> "errors: " + finished.getContext().getErrors()); + assertEquals(List.of("Hello, Alice from HR!", "Hello, Bob from HR!"), + finished.getContext().getResult().values().stream().findFirst().orElseThrow()); + } + @Test public void genericContainerSuspendsForInnerInteractionAndResumesFromChild() { Block innerInteraction = humanInteractiveBlockFactory.create(