Problem
Two defects in one window. Under synchronous execution (the shipped default) a subprocess child is drained inside the call that started it, so the child can reach a terminal state before that caller has finished its own bookkeeping. Two callers start a child, and each mishandles that differently.
1. A child that does not complete strands its parent for ever. A subprocess node parks its parent token while the child runs. When the child ends inside the parent task's own execute(), the child's resumeParent() finds the parent still ACTIVE and does nothing, which is exactly the lost wakeup WorkflowExecutor::resumeFromCompletedChild() exists to catch right after the park. That guard only looks for a child in the completed state. A child that reaches failed or canceled in the same window is not caught, so the parent token stays PARKED for ever: no child is running, the node need declare no timeout, and neither recovery sweep matches it (reconcileStuckInstances() wants an instance with no live token, and PARKED is live; recoverStalledInstances() wants a WAITING token at a join).
The documented contract is the opposite. docs/subprocesses.md says of state_variable: "Named, the parent resumes even when the child does not complete, with this variable set to __subprocess_failed__ or __subprocess_canceled__ so an outgoing flow condition can route the error." That is what happens when the same child fails on a later cron turn; it does not happen when it fails inline.
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: the fresh child therefore 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 writes the node timeout over it.
Both of the node's delays are lost at once. The next re-launch moves from its backoff out to the timeout instant, and the token is still armed for a re-launch, so the sweep that eventually finds it due re-launches rather than running the timeout action. The escalation never fires, and the window in between is spent waiting out a delay nobody authored.
Steps to reproduce
Both need execution_mode left at its shipped synchronous, and "When a step fails for good" set to Fail the whole run on the Orchestra settings page. Author a child workflow whose first step throws a fatal, non-retryable failure.
- For the first defect: a parent of start, then subprocess (that child,
state_variable: child_state), then end. Start it. Observed: the child isfailed, the parent instance is stillrunning, its subprocess token isparked, andchild_statewas never written. Expected: the parent resumes withchild_state = __subprocess_failed__and routes on it, which is what it does when the child fails on a later cron turn. - For the second: give that subprocess node
relaunch_attempts: 2,relaunch_backoff: 60and a node timeout of one day. Start it, then fire the parked token's timer so the re-launch runs. Observed: the parent's deadline is now plus one day. Expected: now plus sixty seconds, the authored backoff.
An explicit cancellation of the child inside the same advance strands the parent the same way as the first defect.
Proposed resolution
Have the post-park guard answer for every terminal child, not only a completed one. The word the parent routes on is not a lookup: it is the child instance's state, which the task spells into __subprocess_<state>__, so it is read off the child rather than tabulated. A table would be a fourth place holding those literals, beside the two instance-ending paths 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, which is the divergence the guard exists to close. The guard stays a no-op while any child of that token is still running.
Give the re-arm the condition the claim four lines above it already uses: apply it 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.
AI-Generated: Yes (Claude Code was used to help draft this issue summary and to write both fixes and their tests. Each defect was reproduced against unpatched 1.x before it was written up, and every new test was confirmed to fail without its change and to pass with it. The re-arm that the second fix guards had no coverage at all, so a test pinning it was added alongside.)
Issue fork orchestra-3623325
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
mably commentedComment #4
mably commentedComment #6
mably commented