Filed from the os-dev seat on #13648 (out-of-scope finding — #13648 is not addressed by this card, and this card does not address #13648). Unassigned and ungraded; PM triage owns domain:*, type and priority.
What was measured
@objectstack/service-automation, AutomationEngine.resumeInternal, the subflow delegation block (if (typeof run.correlation === 'string' && run.correlation.startsWith('subflow:')), packages/services/service-automation/src/engine.ts, around line 4790 on origin/main 8094834).
A parent run paused at a subflow node whose child paused on a screen node with an unconditional required field. Resuming the PARENT with a bag that violates the child screen's contract:
engine.resume(parentRunId, { variables: {} })
Measured (vitest, in-process engine, installBuiltinNodes):
parent resume envelope: {"success":false,"error":"subflow run 'run_…' (child) failed: Invalid screen input: Screen field \"kind\" is required — declared fields: 'kind'","durationMs":7}
parent still suspended: false
child still suspended: true
suspended runs: [{"flow":"child","node":"ask"}]
corrected retry on parent (kind supplied): {"success":false,"code":"RUN_NOT_FOUND","error":"No suspended run 'run_…'"}
Why this is a defect
The child's answer is a RETRYABLE refusal — success: false, code: 'INVALID_SCREEN_INPUT', the child's pause deliberately NOT consumed (#4477: "a rejected bag leaves the pause live and the legitimate submission still lands"). The delegation block reads every !childRes.success as a TERMINAL child failure:
if (!childRes.success) {
const error = `subflow run '${childRunId}' (${childRun.flowName}) failed: …`;
await this.failSuspendedRun(run, error);
return { success: false, error, durationMs };
}
So one mistyped screen submission through the parent (the screen-flow path: the UI holds ONE stable run id — the parent's — and posts every wizard step to it, per subflow-node.test.ts "keeps the parent paused across a multi-screen child wizard"):
- terminally fails the parent run (its suspension is consumed and a failure is recorded),
- leaves the child parked forever with nothing to bubble up to (orphan),
- answers the caller an envelope with NO
code — the transport maps it to 400 FLOW_FAILED ("ran and was rejected") when nothing ran, and the caller's corrected retry on the same run id is RUN_NOT_FOUND.
The same shape holds for every other retryable child refusal the delegation can receive (INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE): all of them leave the child's pause live, and all of them fail the parent.
What would resolve it
Propagate a child REFUSAL as a refusal: when childRes.success === false and the child's suspension is still live (or, producer-first, when childRes.code is one of the refusal codes the engine itself answers), return the child's envelope (code included) and leave BOTH pauses intact — failSuspendedRun only for a child that actually ran and failed terminally. Pins to add: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent AND child still suspended, corrected retry on the parent completes.
Related, not a duplicate: #13909 (a resume consumes the pause before downstream nodes run) is about a node that THROWS after the pause is consumed; this one is a refusal that never consumed the child's pause but consumed the parent's.
Generated by Claude Code
Generated by Claude Code
Filed from the
os-devseat on #13648 (out-of-scope finding — #13648 is not addressed by this card, and this card does not address #13648). Unassigned and ungraded; PM triage ownsdomain:*, type and priority.What was measured
@objectstack/service-automation,AutomationEngine.resumeInternal, the subflow delegation block (if (typeof run.correlation === 'string' && run.correlation.startsWith('subflow:')),packages/services/service-automation/src/engine.ts, around line 4790 onorigin/main8094834).A parent run paused at a
subflownode whose child paused on ascreennode with an unconditionalrequiredfield. Resuming the PARENT with a bag that violates the child screen's contract:Measured (vitest, in-process engine,
installBuiltinNodes):Why this is a defect
The child's answer is a RETRYABLE refusal —
success: false, code: 'INVALID_SCREEN_INPUT', the child's pause deliberately NOT consumed (#4477: "a rejected bag leaves the pause live and the legitimate submission still lands"). The delegation block reads every!childRes.successas a TERMINAL child failure:So one mistyped screen submission through the parent (the screen-flow path: the UI holds ONE stable run id — the parent's — and posts every wizard step to it, per
subflow-node.test.ts"keeps the parent paused across a multi-screen child wizard"):code— the transport maps it to400 FLOW_FAILED("ran and was rejected") when nothing ran, and the caller's corrected retry on the same run id isRUN_NOT_FOUND.The same shape holds for every other retryable child refusal the delegation can receive (
INVALID_SIGNAL,RESUME_IN_PROGRESS,STORE_UNAVAILABLE): all of them leave the child's pause live, and all of them fail the parent.What would resolve it
Propagate a child REFUSAL as a refusal: when
childRes.success === falseand the child's suspension is still live (or, producer-first, whenchildRes.codeis one of the refusal codes the engine itself answers), return the child's envelope (code included) and leave BOTH pauses intact —failSuspendedRunonly for a child that actually ran and failed terminally. Pins to add: refused parent resume ⇒code: 'INVALID_SCREEN_INPUT', parent AND child still suspended, corrected retry on the parent completes.Related, not a duplicate: #13909 (a resume consumes the pause before downstream nodes run) is about a node that THROWS after the pause is consumed; this one is a refusal that never consumed the child's pause but consumed the parent's.
Generated by Claude Code
Generated by Claude Code