Resume a subprocess parent, and keep its re-launch backoff, when its child ends inside the call that started it

Fixes two defects, both in one window: under synchronous execution (the shipped default) a subprocess child is drained inside the call that started it, so it can reach a terminal state before that caller has finished its own bookkeeping. Two callers start a child, and each mishandled that differently.

1. A child that does not complete strands its parent

A subprocess node parks its parent token while its child runs. When the child ends inside the parent task's own execute(), its resumeParent() finds the parent still ACTIVE and does nothing. WorkflowExecutor::resumeFromCompletedChild(), called right after the park, is what catches that -- but it asked only whether the child had completed.

A child that failed or was canceled in the same window was never answered for, and the parent stayed PARKED for ever: no child running, no deadline the node need have declared, and neither recovery sweep matching it (reconcileStuckInstances() wants a run with no live token, and PARKED is live; recoverStalledInstances() wants a token WAITING at a join). docs/subprocesses.md already promises the opposite for state_variable, and it is what the same child gets when it ends on a later cron turn.

resumeFromTerminalChild() answers for every terminal state. The word the parent routes on is not looked up: it is the child instance's state, which SubprocessTask::getVariablesForTerminalChild() spells into __subprocess_<state>__, so it is read off the child. A table mapping the three states to those words would be a fourth place holding the literals, beside cancelInstance(), failInstance() and the coordinator's own failed-child test, and the first to drift would make the inline path write a value the cron path does not -- the divergence the guard exists to close. Those three literals become ProcessInstanceInterface::STATE_* in the same commit.

The guard stands aside while any child of the token is still running, and answers for the most recent one otherwise.

2. A re-launch's backoff is written over by the node timeout

A subprocess node can carry both a re-launch budget and a timeout. SubprocessCoordinator::relaunchDueSubprocess() clears the deadline it was woken by, calls launchChild(), then re-arms the node timeout so a re-launched child that hangs still escalates. start() drains a synchronous child inline whatever the depth -- deliberately, so that a subprocess started mid-advance still drains its own instance -- so the fresh child runs to its end inside that call. A child that fails there arms the next re-launch on the parent it is still parked on, stamping the authored backoff, and the unconditional re-arm then wrote the node timeout over it.

Both of the node's delays went at once. The next re-launch moved from its backoff out to the timeout instant, and the token is still armed, so fireOne() re-launches rather than ever reaching the timeout action: the escalation did not run, and the window in between was spent waiting out a delay nobody authored.

The re-arm now carries the condition the claim four lines above it already uses -- it applies only while the deadline is still the empty one that claim left -- so a backoff armed in between wins, and the parent is never left without a deadline either way.

The tests

SubprocessTest::testSynchronousChildFailingInsideTheAdvanceResumesTheParent() is the synchronous lane of the existing testTerminalChildRoutesParentViaStatusVariable(). The class pins execution_mode: queued in setUp(), which is precisely why the default lane went unexercised, so the test puts the shipped mode back for its own duration.

testTheSynchronousRelaunchKeepsTheBackoffItArmed() asserts a re-launched parent still carries its authored 60-second backoff rather than the node's one-day timeout.

testTheRelaunchReArmsTheNodeTimeout() is its queued counterpart, and pins the re-arm itself: deleting that re-arm outright left every timeout and subprocess class green, so without this test the new condition would be indistinguishable from removing the re-arm.

On the test-only changes lane the first two fail against unpatched code, one per defect. The third passes on both sides, which is what it is for.

AI-Generated: Yes (Claude Code was used to write both changes and their tests. Each defect was reproduced against unpatched 1.x before it was written up, and every new test was toggled both ways -- reverted, and overshot by deleting the behaviour around it -- to confirm it fails without its change and pins something with it.)

Edited by Frank Mably

Merge request reports

Loading
Loading