Skip to content

service-automation: a subflow parent resume treats the delegated child screen's RETRYABLE refusal (INVALID_SCREEN_INPUT) as a terminal child failure — the parent is failed, the still-paused child is orphaned, and the corrected retry answers RUN_NOT_FOUND #14379

Description

@claude

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

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions