Uh oh!
There was an error while loading. Please reload this page.
fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure - #14567
Conversation
…ntract (#14379) Red half of the reproduction: a parent resume delegated to a child paused on a screen with a `required` field answers a code-less envelope, fails the parent and orphans the still-paused child. The negative control (a child that really ran and threw) is green on both sides. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…l instead of failing the parent (#14379) The subflow delegation block read every `!childRes.success` as a child that ran and died. A retryable refusal — the codes `resumeInternal` itself answers for a resume that never ran — left the child parked where it was, but consumed the PARENT's pause, recorded a failure, and answered a code-less envelope the transport maps to `400 FLOW_FAILED`; the corrected retry then answered `RUN_NOT_FOUND`. Branch on the child's own `code` (producer-first, per the triage ruling), return the child's envelope with the code intact and both pauses untouched, and reserve `failSuspendedRun` for a child that genuinely ran and failed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 12ede636a4b77f1c4df9993fade21b1996dd7e3a && git checkout 12ede636a4b77f1c4df9993fade21b1996dd7e3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e68ed25cc45c15ce296c299614b2b51a3296e52 4ae326704950de2991b47cf21bd71c5601bc536f && git checkout -B drift-repro 0e68ed25cc45c15ce296c299614b2b51a3296e52 && git merge --no-ff 4ae326704950de2991b47cf21bd71c5601bc536f
node scripts/docs-audit/affected-docs.mjs --json 0e68ed25cc45c15ce296c299614b2b51a3296e52
|
os-sales
commented
Sep 2, 2026
Landing provenance — ready + auto-merge at head |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14379
A parent run paused at a
subflownode forwards a resume down to the child it is parked on — the screen-flow path, where the caller holds ONE stable run id (the parent's) and posts every wizard step to it. When the child refused that bag, the delegation block read it as a child that ran and died: it calledfailSuspendedRunon the parent and answered a code-less{ success: false, error }. One mistyped form field therefore destroyed a running workflow — the parent's suspension consumed and a failure recorded, the still-paused child orphaned with nothing left to bubble into, the caller told400 FLOW_FAILED("it ran and was rejected") for something that never ran, and their corrected retry on the same run id answeredRUN_NOT_FOUND.The delegation now branches on the child's own
code. A retryable refusal is answered as a refusal, with itscodeintact and both pauses untouched;failSuspendedRunis reserved for a child that genuinely ran and failed.Head at the time of every measurement below:
4ae326704.Ruling of record (14379#issuecomment-5504400729, verbatim)
Serialisation honoured: the branch is cut from
origin/mainafter PR #14388 merged, and theelsearm carrying the "child run … is gone" log line is byte-untouched — #14392 stays a separate diff, still open.The change
packages/services/service-automation/src/engine.ts, two additions and nothing else:RETRYABLE_RESUME_REFUSAL_CODES, naming the codesresumeInternalitself answers for a resume that never ran —INVALID_SCREEN_INPUT,INVALID_SIGNAL,RESUME_IN_PROGRESS,STORE_UNAVAILABLE— plus the one-line predicate that reads it.RUN_NOT_FOUNDis deliberately absent and the docblock says why: it is the engine's terminal "this pause is gone for good" class (the automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 comment sitting 30 lines above), which a transport answers 404 and no retry can fix.durationMs, consuming neither pause and refreshing nothing (the child did not advance, so the parent's surfaced screen is already current).No log site was added at any level: a refusal is a response, not a degradation, and the child already logged its own
warnwhere it produced the refusal.Premise checks on
origin/main(all verified before the first edit)resumeInternal's subflow block still has exactly three arms, no refusal armengine.ts:4798byrun.correlation.startsWith('subflow:'); the three arms read exactly as the card quotes themexpected undefined to be 'INVALID_SCREEN_INPUT'/'INVALID_SIGNAL'origin/main;refuseInvalidScreenInput/ENGINE_BUILT_SIGNALuntouched{}is at the public door; neither symbol appears in the diffresumeInternal:RESUME_IN_PROGRESS,STORE_UNAVAILABLE,RUN_NOT_FOUND(three sites),INVALID_SCREEN_INPUT(viarefuseInvalidScreenInput),INVALID_SIGNAL. No other code is produced there. Nothing was invented andpackages/specwas not touchedpackages/spec,content/docs/releases/**and the #14392 log line untouchedengine.ts, one new test file, one changesetP4 reachability note (reported, not acted on). Two of the four are reachable through delegation today and are pinned end to end:
INVALID_SCREEN_INPUTandINVALID_SIGNAL. The other two are in the set because the producer answers them from this method, but neither has a deterministic fixture:RESUME_IN_PROGRESSneeds a real race window against a concurrent direct child resume, and aSTORE_UNAVAILABLEoutage trips the parent's ownloadSuspendedRunStrictseveral frames earlier, so the parent never reaches the delegation block at all. Keeping them in the set is the producer-first rule applied whole; the pins claim only what was measured.Hypotheses (each falsifiable, each with its evidence)
failSuspendedRun, no pause consumed: HOLDS. That is the whole source change.forgetSuspendedRun(run, 'resumed')— the one consumption on this path — sits ~90 lines after the delegation block, and the only other consumer is thefailSuspendedRunthe new arm bypasses.creditChildRunreturns immediately whenchildSummaryis absent, which it is on a direct parent resume. Proven behaviourally, not just by reading:hasSuspendedRun(parentRunId)istrueafter the refusal in every refusal pin.{ kind: 'normal' }. Both suspensions are gone afterwards.failSuspendedRun, envelope shape unchanged: HOLDS. A child whose node throws after the screen accepted the bag still answerssuccess: false,codeundefined,errormatchingsubflow run '…' (child_flow) failed: …, with both suspensions consumed. This test is green on both sides of the change — it is the control that stops the arm from passing by never failing anything.Tests
New file
packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts— 5 pins, built oninstallBuiltinNodeswith realsubflowandscreennodes, exactly the composition the card measured.success: false,code: 'INVALID_SCREEN_INPUT', the child's own actionable text, parent and child still suspended, the parent's surfaced screen unchanged, downstream never ranresume(parentRunId)⇒ the same refusal, both pauses intact, corrected retry still lands (the population the scope note 14379#issuecomment-5504169090 adds)INVALID_SIGNALfrom a reserved variable name against a child pause that declares no screen contract ⇒ same shape, then the legitimate submission landscodeundefined, error text unchangedEvery refusal pin asserts the ADR-0112
code(and the error text), never a bare "it failed".Verdict lines, quoted from the runs, all at
4ae326704:Type check: this package declares no
typecheckscript and carries a frozen DEBT ledger entry of 3.tsc --noEmit -p tsconfig.jsonreports exactly those 3 pre-existingTS2341insrc/nested-region-parity.test.ts(a file this diff does not touch) — unchanged.--listFilesconfirms both edited files really are in that program (engine.tsandsubflow-child-refusal.test.tsboth listed), so this is a measurement and not a green over source nothing read.Ablation (on the committed tree, both legs proven on disk)
Mutation: the new arm's guard neutralised in place, with a greppable sentinel so the on-disk change is provable from two directions.
Predicted direction, observed: pins a–d red, pin e (the negative control) green. Restore verified by blob-hash equality against the HEAD blob and an empty
git diff HEAD, not by an exit code; the script carried atrap … EXIT INT TERMwith absolute paths throughout, and an earlier attempt that produced a zero-match substitution aborted at the guard rather than reporting a green ablation — it is reported here as an admitted no-op run, not quietly retried.No rebuild is required for this ablation and none was performed. The pin imports the subject relatively (
../engine.js, same package), which vitest resolves tosrc/engine.ts, and the package's onlyresolve.aliasentry is an unrelated one for@objectstack/platform-objects. That is proven positively rather than asserted: mutatingsrc/engine.tsalone, with no build, flipped the suite red, and restoring it alone flipped it green.Gates
Derived on the final head from the actual change set, never a hand-written list:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat4ae326704— 36 commands over the 3 changed paths. 33 exited 0. Three arePREREQUISITE NOT MET, recorded as NOT MEASURED and not as passes, each with the gate's own words:check-test-completeness(exit 3) — "this gate grades a savedturbo run testlog, and no log was named … the local reading for this gate is NOT MEASURED. ⛔ It is not a red".check:dual-build-cjs-loads(exit 3) — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (Its own self-test passed: "93 cases pass".)check:type-check-debt(exit 3) — "--re-measure cannot run: 33 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding". Its siblingcheck:type-check-coveragedid run and passed: "OK — 68/78 workspace packages type-checked".Every exit code above was captured before any pipe (
cmd > file 2>&1; EXIT=$?), and each verdict is quoted from the gate's own output rather than read off a bare$?.Beyond the derived family:
check:nul-bytespassed ("scanned 7940 text file(s) … no raw ASCII control bytes"), and a direct control-byte scan over the three changed files returned no hits.Repo-wide lint was run in full, not narrowed —
pnpm lint(eslint . --no-inline-configover the whole repo) exited 0.git merge-tree --write-tree --name-only origin/main HEADreturned a clean tree with no file list, socontent/docs/permissions/system-context.mdxis not implicated and no regeneration is owed.One honest caveat on the derivation: re-running it after a fresh fetch warned "STALE TREE — this answer is derived from a tree at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED across that range …
.github/workflows/lint.ymlscripts/role-word-baseline.json". Both were inspected: thelint.ymlchange is comment-only (no step added or removed) androle-word-baseline.jsonmoved by one line, so the family for these paths is unchanged. CI runs the real farm regardless.Clause-②: no
Declared from the actual diff, not from the plan:
git diff -U0 origin/main...HEAD | grep exportreturns exactly one line, a comment in the new test file ("parks on a realscreennode and exports what it collected"). No export was added, removed or renamed, and no accept set moved —RETRYABLE_RESUME_REFUSAL_CODESandisRetryableResumeRefusalare module-private. The publicresumecontract already declares all four codes as answers (packages/spec/src/contracts/automation-service.ts); this change makes the parent's resume return one instead of swallowing it.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code
Generated by Claude Code