You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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/main8094834).
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:
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:
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.
Scope widening from the Clause-② review of PR #14388 (#13648), recorded here by the domain:services PM seat (session_01AUF1NoViznQK32gqpK8wS8): after that PR, a signal-less resume(parentRunId) on a subflow parent whose delegated child is paused on a screen with a required field also lands on this card's path — engine.ts:4225 normalises the absent signal to {} → :4804resumeInternal(child, {}, true) → the child refuses INVALID_SCREEN_INPUT → :4820-4823failSuspendedRun(parent) with a code-less { success:false, error }. Same behaviour {} already produced (this card's measurement), now reachable by the signal-less gesture as well; not reachable through any external door (the REST handler always assembles {}), so it is this card's population, not #13648's. Line numbers are at PR #14388's head 8ed17503.
Triage: bug · priority:p1 · pm:queue · domain:services · type Bug. Not blocked — the defect is on main today and no upstream gates the fix.
Face verified on origin/main @ 5c9e40a, read from the code rather than from the report. The delegation block is at engine.ts:4778-4816, and the three branches are exactly as the card describes:
!childRes.success → failSuspendedRun(run, error) and return (:4800-4804) — verbatim as quoted;
child completed → signal = this.buildSubflowResumeSignal(...) (:4808).
So a refusal, which is success: false with the child's pause deliberately still live, falls into the branch written for a child that ran and died. There is no third arm. ✔
Why p1, in one sentence: a mistyped form field destroys a running workflow. Not "returns a confusing error" — the parent's suspension is consumed and a failure recorded, so the run is gone; the child is parked forever with nothing left to bubble into; and the caller, who holds the one stable run id the screen-flow path gives them, is told 400 FLOW_FAILED ("it ran and was rejected") for something that never ran, then RUN_NOT_FOUND when they fix their input and retry. Every step of that sequence points the user away from the actual problem, which was a missing required field they could have corrected in two seconds. Work loss on the ordinary wizard path is not a p2.
The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own code being one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else. failSuspendedRun is then reserved for a child that genuinely ran and failed.
⚠️And propagate the code. The current envelope carries none, which is why the transport lands on 400 FLOW_FAILED. Returning the child's envelope with its code intact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.
The pins the card names are the right ones, and one more: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.
⚠️ Serialisation: three cards are live on these same 40 lines
Whoever takes this must check the other two before starting, and ⛔ must not fold them in:
Related but distinct, and the card is right to say so:#13909 is a node that throws after the pause was consumed; this is a refusal that never consumed the child's pause and consumed the parent's. ⛔ Not a duplicate, and neither fix substitutes for the other.
Container & model: in-process os-dev subagent, model opus (triage's size/model suggestion: M, sonnet; the seat keeps its default opus for an engine state-machine repair). node scripts/pm/dispatch-gates.mjs --tier packages/services/service-automation/src/engine.ts on fed4fa409: "Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s) … Clause ② is NOT reachable from paths … This line is a FLOOR, never a clearance."
Clause-②: no (expected; the dev re-declares from the actual diff) — the public resume contract already declares INVALID_SCREEN_INPUT and the other refusal codes as answers; the change makes the parent's resume answer the child's refusal instead of terminally failing the run. No new export expected. If the diff adds an export or moves an accept set ⇒ yes, carriers on both sides.
Ruling of record: triage comment 14379#issuecomment-5504400729 (producer-first discriminator: branch on the child's own code ∈ {INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE}, never on "is the child's suspension still live"; propagate the code; failSuspendedRun reserved for a child that genuinely ran and failed; pins: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent AND child still suspended, corrected retry completes; negative control: a child that terminally throws still fails the parent), plus the scope note 14379#issuecomment-5504169090 (the signal-less gesture now lands on this path too).
Race re-read follows this comment before the dev is launched.
{
"issue": 14379,
"status": "done",
"branch": "claude/issue-14379-subflow-child-refusal-propagation",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14567",
"head_sha": "4ae326704950de2991b47cf21bd71c5601bc536f",
"premise_still_valid": true,
"premise_checks": {
"P1": "holds — engine.ts:4798, three arms exactly as the card quotes; no refusal arm existed",
"P2": "holds — reproduced as a failing test BEFORE any source edit: 4 failed | 1 passed, every failure \"expected undefined to be INVALID_SCREEN_INPUT / INVALID_SIGNAL\"",
"P3": "holds — PR #14388 is on origin/main (signal-less resume normalised to {} at the public door); refuseInvalidScreenInput and ENGINE_BUILT_SIGNAL are byte-untouched",
"P4": "holds — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (x3), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. The four the ruling names are all produced there; no code invented, packages/spec untouched. RUN_NOT_FOUND deliberately excluded as the terminal \"pause is gone for good\" class (the #8684 comment). Reachability note: only INVALID_SCREEN_INPUT and INVALID_SIGNAL are deterministically reachable through delegation and both are pinned; RESUME_IN_PROGRESS needs a real race window, and a STORE_UNAVAILABLE outage trips the PARENT own loadSuspendedRunStrict several frames earlier so the delegation block is never reached. Both stay in the set (producer-first applied whole); the pins claim only what was measured.",
"P5": "holds — diff is 3 files: engine.ts, one new test file, one changeset. packages/spec, content/docs/releases/** and the #14392 log line in the else arm are untouched"
},
"hypotheses": {
"H1": "HOLDS — one new arm before the terminal-failure arm, returns the child envelope (spread) with the parent durationMs, no failSuspendedRun, neither pause consumed, no screen refresh (the child did not advance)",
"H2": "REFUTED as a worry / no restoration needed — the parent pause is NOT consumed before the delegation block: forgetSuspendedRun(run, resumed) sits ~90 lines after it and the only other consumer is the failSuspendedRun the new arm bypasses; creditChildRun returns immediately when childSummary is absent (it is, on a direct parent resume). Proven behaviourally too: hasSuspendedRun(parentRunId) === true after every refusal pin",
"H3": "HOLDS — corrected retry on the same parent run id completes end to end: child screen accepts, child completes, engine-built signal maps the child output into the parent, downstream observes { kind: normal }, both suspensions gone",
"H4": "HOLDS — negative control (child node throws after the screen accepted the bag) still fails the parent via failSuspendedRun: success false, code undefined, error matches \"subflow run ... (child_flow) failed: ...\", both suspensions consumed. Green on BOTH sides of the change, which is what stops the arm passing by never failing anything"
},
"tests": "New pin file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts (5 pins on installBuiltinNodes with real subflow+screen nodes; every refusal pin asserts the ADR-0112 code, never a bare toThrow). All measurements at head 4ae326704. RED HALF (before the source edit, same file): \"Test Files 1 failed (1) / Tests 4 failed | 1 passed (5)\", \"AssertionError: expected undefined to be INVALID_SCREEN_INPUT\". GREEN: targeted (new pins + subflow-node + both screen-resume suites) \"Test Files 4 passed (4) / Tests 41 passed (41)\", VERDICT command-exit 0; whole package \"Test Files 99 passed (99) / Tests 1171 passed (1171)\", VERDICT command-exit 0; downstream consumer @objectstack/plugin-approvals after building its closure \"Test Files 35 passed (35) / Tests 652 passed (652)\", VERDICT command-exit 0. TYPECHECK: package declares no typecheck script and carries a frozen DEBT ledger entry of 3; tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (untouched file) — unchanged — and --listFiles confirms BOTH edited files are in that program, so it is a measurement, not a green over unread source.",
"ablation": "On the COMMITTED tree, both legs proven on disk, no rebuild needed and none performed. HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625; PRE marker=1 sentinel=0 -> POST marker=0 sentinel=1; MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e (differs from HEAD blob). Ablated run: \"Test Files 1 failed (1) / Tests 4 failed | 1 passed (5)\" — predicted direction observed exactly (pins a-d red, the negative control green). RESTORE leg proven the same way: RESTORED_BLOB == HEAD_BLOB, marker=1 sentinel=0, git diff HEAD empty, git status --porcelain empty; script carried trap ... EXIT INT TERM with absolute paths. NO REBUILD REQUIRED, proven positively not asserted: the pin imports the subject relatively (../engine.js, same package) which vitest resolves to src/engine.ts, and the package vitest.config.ts has exactly one resolve.alias entry, for the unrelated @objectstack/platform-objects; mutating src/engine.ts alone with no build flipped the suite red and restoring it alone flipped it green. ADMITTED NO-OP: a first attempt used a perl pattern that matched ZERO times; the on-disk guard caught it (PRE marker=1 / POST marker=1) and the script aborted with \"MUTATION-NOT-APPLIED — this ablation did NOT run\" instead of reporting a green ablation.",
"gates": "36 commands derived on the FINAL head from the actual change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 (3 paths). 33 exited 0. 3 are NOT MEASURED (exit 3, PREREQUISITE NOT MET — the gates own words, not a red and not a pass): check-test-completeness (\"this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED\"), check:dual-build-cjs-loads (\"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 (\"--re-measure cannot run: 33 workspace dependenc(ies) ... have no built type entry point on disk\"; its sibling check:type-check-coverage DID run and passed, \"OK — 68/78 workspace packages type-checked\"). Every exit captured BEFORE any pipe (cmd > file 2>&1; EXIT=0) and every verdict quoted from the gate own output. Beyond the family: check:nul-bytes passed (\"scanned 7940 text file(s) ... no raw ASCII control bytes\") plus a direct control-byte grep over the 3 changed files (no hits). REPO-WIDE LINT RUN IN FULL, NOT NARROWED: pnpm lint (eslint . --no-inline-config over the whole repo) exited 0. Pre-PR: git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with NO file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed. Caveat reported rather than buried: a re-derivation after a fresh fetch warned \"STALE TREE — ... at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED\" (.github/workflows/lint.yml, scripts/role-word-baseline.json); both inspected — the lint.yml change is comment-only (no step added or removed) and the baseline moved by one line, so the family for these paths is unchanged, and the re-derived command list is byte-identical to the first.",
"clause_2": "no — declared from the ACTUAL diff: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a COMMENT in the new test file. No export added/removed/renamed, no accept set moved; RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes (packages/spec/src/contracts/automation-service.ts). No needs:contract-review label hung.",
"log_levels": "no new log site at any level — a refusal is a response, not a degradation, and the child already logs its own warn where it produces the refusal",
"changeset": ".changeset/subflow-child-refusal-propagation.md — @objectstack/service-automation patch (published package); states the behaviour change and that a terminal child failure still fails the parent. No skip-changeset label (a changeset is present).",
"mcp_calls": "0 — every GitHub read and write went through the container REST channel (repo-scoped probe returned 200 at the start of the run); zero MCP GitHub calls",
"summary": "The subflow delegation block in AutomationEngine.resumeInternal read every !childRes.success as a child that ran and died, so a delegated child REFUSAL — the codes resumeInternal itself answers for a resume that never ran — consumed the PARENT pause via failSuspendedRun, orphaned the still-paused child, and answered a code-less envelope the transport maps to 400 FLOW_FAILED, after which the corrected retry answered RUN_NOT_FOUND. The change adds a module-private closed set of those codes plus a one-line predicate, and one new arm placed BEFORE the terminal-failure arm that returns the child envelope with its code intact and the parent durationMs, consuming neither pause. Producer-first per the ruling: it branches on the child own code, never on whether the child suspension is still live. RUN_NOT_FOUND is deliberately excluded (the terminal #8684 class). Consumer note carried on #13648 checked: nothing here changes the signal-carrying refusal path that objectstack-ai/hotcrm#1173 pins end to end — the refusal PRODUCER (refuseInvalidScreenInput) is untouched; only the parent delegation arm changed. Serialisation honoured: branch cut from origin/main after PR #14388 merged, and the else arm carrying the \"child run ... is gone\" log line (#14392) is byte-untouched.",
"open_questions": [],
"out_of_scope_findings": []
}
ACCEPT — PR #14567 at head 4ae326704; landing waits only for CI on this head
Reviewer of record: domain:services PM seat, session_01AUF1NoViznQK32gqpK8wS8 (os-sales). Verified against the tree and GitHub, not against the report:
Shape: draft, base main, Fixes #14379 on the first line (the merge closes this card — correct); mergeable_state: blocked = draft with checks pending; git merge-tree against origin/main7286dd58e clean (no system-context.mdx involvement).
Ruling conformance (14379#issuecomment-5504400729): producer-first discriminator on the child's own code — the four codes the ruling names, RUN_NOT_FOUND deliberately excluded with the reason on record; the code is propagated (the child envelope returned verbatim but for durationMs); failSuspendedRun reserved for a child that genuinely ran and failed; the negative control the ruling demanded is pin (e) and is green on both sides of the change.
Clause-②: no, verified by the seat — git diff -U0 merge-base..4ae326704 | grep export is empty on code (the one hit is a comment in the test file); no accept set moves — the public resume contract already declares all four codes; no carrier hung.
Log levels: no new log call at any level in the diff.
Evidence read: red half before the edit (4 failed | 1 passed), targeted 41/41, whole package 99 files / 1171, downstream plugin-approvals 35 / 652 after building its closure, ablation with on-disk proof both legs (plus one admitted no-op attempt correctly aborted by the marker guard), 33 gates green + 3 NOT MEASURED (CI's), repo-wide pnpm lint exit 0, typecheck measured with --listFiles (both edited files in the program; the 3 pre-existing DEBT errors unchanged).
P4 reachability note accepted as recorded: RESUME_IN_PROGRESS and STORE_UNAVAILABLE stay in the set by the producer-first rule; only the two deterministically reachable codes are pinned, and the report says so.
MCP reads git could have answered: none (zero MCP calls).
Landing: when every check on 4ae326704 is green (Lint & Repo Gates and TypeScript Type Check by job conclusion), draft → ready → auto-merge → provenance. On MERGED: card auto-closes; strip pm:dispatched; engine.ts is released to #14332 / #14333 / #14222 / #14392 (one at a time).
Activity
os-sales commentedon Sep 2, 2026
Scope widening from the Clause-② review of PR #14388 (#13648), recorded here by the
domain:servicesPM seat (session_01AUF1NoViznQK32gqpK8wS8): after that PR, a signal-lessresume(parentRunId)on a subflow parent whose delegated child is paused on a screen with arequiredfield also lands on this card's path —engine.ts:4225normalises the absent signal to{}→:4804resumeInternal(child, {}, true)→ the child refusesINVALID_SCREEN_INPUT→:4820-4823failSuspendedRun(parent)with a code-less{ success:false, error }. Same behaviour{}already produced (this card's measurement), now reachable by the signal-less gesture as well; not reachable through any external door (the REST handler always assembles{}), so it is this card's population, not #13648's. Line numbers are at PR #14388's head8ed17503.Generated by Claude Code
huangyiirene commentedon Sep 2, 2026
Triage:
bug·priority:p1·pm:queue·domain:services· typeBug. Not blocked — the defect is onmaintoday and no upstream gates the fix.Face verified on
origin/main@5c9e40a, read from the code rather than from the report. The delegation block is atengine.ts:4778-4816, and the three branches are exactly as the card describes:childRes.status === 'paused'→ both stay suspended, parent's screen refreshed (:4784-4799);!childRes.success→failSuspendedRun(run, error)and return (:4800-4804) — verbatim as quoted;signal = this.buildSubflowResumeSignal(...)(:4808).So a refusal, which is
success: falsewith the child's pause deliberately still live, falls into the branch written for a child that ran and died. There is no third arm. ✔Why p1, in one sentence: a mistyped form field destroys a running workflow. Not "returns a confusing error" — the parent's suspension is consumed and a failure recorded, so the run is gone; the child is parked forever with nothing left to bubble into; and the caller, who holds the one stable run id the screen-flow path gives them, is told
400 FLOW_FAILED("it ran and was rejected") for something that never ran, thenRUN_NOT_FOUNDwhen they fix their input and retry. Every step of that sequence points the user away from the actual problem, which was a missing required field they could have corrected in two seconds. Work loss on the ordinary wizard path is not a p2.The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own
codebeing one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT,INVALID_SIGNAL,RESUME_IN_PROGRESS,STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else.failSuspendedRunis then reserved for a child that genuinely ran and failed.code. The current envelope carries none, which is why the transport lands on400 FLOW_FAILED. Returning the child's envelope with itscodeintact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.The pins the card names are the right ones, and one more: refused parent resume ⇒
code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.Whoever takes this must check the other two before starting, and ⛔ must not fold them in:
resume(runId)with no signal object bypasses the screen-input contract entirely —refuseInvalidScreenInputshort-circuits on a falsy signal,requiredfields included #13648) — in flight, same block. Rebase on it rather than racing it.elseof the veryif (childRun)this card's arm lives inside. Graded p3 this round. Two separate diffs: a log-text fix disappearing inside a state-machine repair is how the state-machine repair stops getting reviewed on its own merits.Related but distinct, and the card is right to say so: #13909 is a node that throws after the pause was consumed; this is a refusal that never consumed the child's pause and consumed the parent's. ⛔ Not a duplicate, and neither fix substitutes for the other.
Size/model suggestion: M,
sonnet.Generated by Claude Code
os-sales commentedon Sep 2, 2026
Claim:
domain:servicesPM seat, sessionsession_01AUF1NoViznQK32gqpK8wS8(GitHubos-sales), 2026-09-02 ~11:30Z.session_01AUF1NoViznQK32gqpK8wS8claude/issue-14379-subflow-child-refusal-propagation/home/user/objectstack-issue-14379(dev-created, worktree-first)domain:services(packages/services/service-automation)packages/services/service-automation/src/engine.ts— ONLY the subflow delegation block insideresumeInternal(theif (typeof run.correlation === 'string' && run.correlation.startsWith('subflow:'))block, around :4778-4816 onorigin/mainat triage time; re-locate on the current tree); new or extended tests underpackages/services/service-automation/src/builtin/*.test.ts(natural home:subflow-node.test.tsor a newsubflow-child-refusal.test.ts); one changeset in.changeset/. ⛔ Not touched: theelsearm of the sameif (childRun)that carries the "child run … is gone" log line (that is [finding] service-automation: the subflow up-bubble path always logs "child run … is gone — continuing without child output" even when the engine-built signal carries the child's output #14392, p3, a separate diff by triage's instruction),refuseInvalidScreenInput/ENGINE_BUILT_SIGNAL(landed by PR fix(automation): hold a signal-less resume to the screen-input contract (#13648) #14388),packages/spec/**,content/docs/releases/**.resume(runId)with no signal object bypasses the screen-input contract entirely —refuseInvalidScreenInputshort-circuits on a falsy signal,requiredfields included #13648) is MERGED (04:47Z), so the "rebase on it" instruction in triage is satisfied by branching from currentorigin/main; all open PR heads diffed against their merge-base at 11:05Z and re-scanned at ~11:25Z — no open PR holdsengine.ts. [finding] service-automation: the subflow up-bubble path always logs "child run … is gone — continuing without child output" even when the engine-built signal carries the child's output #14392 stays in the queue, not folded in.os-devsubagent, model opus (triage's size/model suggestion: M,sonnet; the seat keeps its default opus for an engine state-machine repair).node scripts/pm/dispatch-gates.mjs --tier packages/services/service-automation/src/engine.tsonfed4fa409: "Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s) … Clause ② is NOT reachable from paths … This line is a FLOOR, never a clearance."resumecontract already declaresINVALID_SCREEN_INPUTand the other refusal codes as answers; the change makes the parent's resume answer the child's refusal instead of terminally failing the run. No new export expected. If the diff adds an export or moves an accept set ⇒yes, carriers on both sides.code∈ {INVALID_SCREEN_INPUT,INVALID_SIGNAL,RESUME_IN_PROGRESS,STORE_UNAVAILABLE}, never on "is the child's suspension still live"; propagate thecode;failSuspendedRunreserved for a child that genuinely ran and failed; pins: refused parent resume ⇒code: 'INVALID_SCREEN_INPUT', parent AND child still suspended, corrected retry completes; negative control: a child that terminally throws still fails the parent), plus the scope note 14379#issuecomment-5504169090 (the signal-less gesture now lands on this path too).Race re-read follows this comment before the dev is launched.
Generated by Claude Code
test(service-automation): pin the subflow child-refusal delegation co…
fix(service-automation): propagate a delegated subflow child's refusa…
claude commentedon Sep 2, 2026
os-dev-report
{ "issue": 14379, "status": "done", "branch": "claude/issue-14379-subflow-child-refusal-propagation", "pr": "https://github.com/objectstack-ai/objectstack/pull/14567", "head_sha": "4ae326704950de2991b47cf21bd71c5601bc536f", "premise_still_valid": true, "premise_checks": { "P1": "holds — engine.ts:4798, three arms exactly as the card quotes; no refusal arm existed", "P2": "holds — reproduced as a failing test BEFORE any source edit: 4 failed | 1 passed, every failure \"expected undefined to be INVALID_SCREEN_INPUT / INVALID_SIGNAL\"", "P3": "holds — PR #14388 is on origin/main (signal-less resume normalised to {} at the public door); refuseInvalidScreenInput and ENGINE_BUILT_SIGNAL are byte-untouched", "P4": "holds — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (x3), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. The four the ruling names are all produced there; no code invented, packages/spec untouched. RUN_NOT_FOUND deliberately excluded as the terminal \"pause is gone for good\" class (the #8684 comment). Reachability note: only INVALID_SCREEN_INPUT and INVALID_SIGNAL are deterministically reachable through delegation and both are pinned; RESUME_IN_PROGRESS needs a real race window, and a STORE_UNAVAILABLE outage trips the PARENT own loadSuspendedRunStrict several frames earlier so the delegation block is never reached. Both stay in the set (producer-first applied whole); the pins claim only what was measured.", "P5": "holds — diff is 3 files: engine.ts, one new test file, one changeset. packages/spec, content/docs/releases/** and the #14392 log line in the else arm are untouched" }, "hypotheses": { "H1": "HOLDS — one new arm before the terminal-failure arm, returns the child envelope (spread) with the parent durationMs, no failSuspendedRun, neither pause consumed, no screen refresh (the child did not advance)", "H2": "REFUTED as a worry / no restoration needed — the parent pause is NOT consumed before the delegation block: forgetSuspendedRun(run, resumed) sits ~90 lines after it and the only other consumer is the failSuspendedRun the new arm bypasses; creditChildRun returns immediately when childSummary is absent (it is, on a direct parent resume). Proven behaviourally too: hasSuspendedRun(parentRunId) === true after every refusal pin", "H3": "HOLDS — corrected retry on the same parent run id completes end to end: child screen accepts, child completes, engine-built signal maps the child output into the parent, downstream observes { kind: normal }, both suspensions gone", "H4": "HOLDS — negative control (child node throws after the screen accepted the bag) still fails the parent via failSuspendedRun: success false, code undefined, error matches \"subflow run ... (child_flow) failed: ...\", both suspensions consumed. Green on BOTH sides of the change, which is what stops the arm passing by never failing anything" }, "tests": "New pin file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts (5 pins on installBuiltinNodes with real subflow+screen nodes; every refusal pin asserts the ADR-0112 code, never a bare toThrow). All measurements at head 4ae326704. RED HALF (before the source edit, same file): \"Test Files 1 failed (1) / Tests 4 failed | 1 passed (5)\", \"AssertionError: expected undefined to be INVALID_SCREEN_INPUT\". GREEN: targeted (new pins + subflow-node + both screen-resume suites) \"Test Files 4 passed (4) / Tests 41 passed (41)\", VERDICT command-exit 0; whole package \"Test Files 99 passed (99) / Tests 1171 passed (1171)\", VERDICT command-exit 0; downstream consumer @objectstack/plugin-approvals after building its closure \"Test Files 35 passed (35) / Tests 652 passed (652)\", VERDICT command-exit 0. TYPECHECK: package declares no typecheck script and carries a frozen DEBT ledger entry of 3; tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (untouched file) — unchanged — and --listFiles confirms BOTH edited files are in that program, so it is a measurement, not a green over unread source.", "ablation": "On the COMMITTED tree, both legs proven on disk, no rebuild needed and none performed. HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625; PRE marker=1 sentinel=0 -> POST marker=0 sentinel=1; MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e (differs from HEAD blob). Ablated run: \"Test Files 1 failed (1) / Tests 4 failed | 1 passed (5)\" — predicted direction observed exactly (pins a-d red, the negative control green). RESTORE leg proven the same way: RESTORED_BLOB == HEAD_BLOB, marker=1 sentinel=0, git diff HEAD empty, git status --porcelain empty; script carried trap ... EXIT INT TERM with absolute paths. NO REBUILD REQUIRED, proven positively not asserted: the pin imports the subject relatively (../engine.js, same package) which vitest resolves to src/engine.ts, and the package vitest.config.ts has exactly one resolve.alias entry, for the unrelated @objectstack/platform-objects; mutating src/engine.ts alone with no build flipped the suite red and restoring it alone flipped it green. ADMITTED NO-OP: a first attempt used a perl pattern that matched ZERO times; the on-disk guard caught it (PRE marker=1 / POST marker=1) and the script aborted with \"MUTATION-NOT-APPLIED — this ablation did NOT run\" instead of reporting a green ablation.", "gates": "36 commands derived on the FINAL head from the actual change set: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 (3 paths). 33 exited 0. 3 are NOT MEASURED (exit 3, PREREQUISITE NOT MET — the gates own words, not a red and not a pass): check-test-completeness (\"this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED\"), check:dual-build-cjs-loads (\"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 (\"--re-measure cannot run: 33 workspace dependenc(ies) ... have no built type entry point on disk\"; its sibling check:type-check-coverage DID run and passed, \"OK — 68/78 workspace packages type-checked\"). Every exit captured BEFORE any pipe (cmd > file 2>&1; EXIT=0) and every verdict quoted from the gate own output. Beyond the family: check:nul-bytes passed (\"scanned 7940 text file(s) ... no raw ASCII control bytes\") plus a direct control-byte grep over the 3 changed files (no hits). REPO-WIDE LINT RUN IN FULL, NOT NARROWED: pnpm lint (eslint . --no-inline-config over the whole repo) exited 0. Pre-PR: git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with NO file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed. Caveat reported rather than buried: a re-derivation after a fresh fetch warned \"STALE TREE — ... at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED\" (.github/workflows/lint.yml, scripts/role-word-baseline.json); both inspected — the lint.yml change is comment-only (no step added or removed) and the baseline moved by one line, so the family for these paths is unchanged, and the re-derived command list is byte-identical to the first.", "clause_2": "no — declared from the ACTUAL diff: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a COMMENT in the new test file. No export added/removed/renamed, no accept set moved; RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes (packages/spec/src/contracts/automation-service.ts). No needs:contract-review label hung.", "log_levels": "no new log site at any level — a refusal is a response, not a degradation, and the child already logs its own warn where it produces the refusal", "changeset": ".changeset/subflow-child-refusal-propagation.md — @objectstack/service-automation patch (published package); states the behaviour change and that a terminal child failure still fails the parent. No skip-changeset label (a changeset is present).", "mcp_calls": "0 — every GitHub read and write went through the container REST channel (repo-scoped probe returned 200 at the start of the run); zero MCP GitHub calls", "summary": "The subflow delegation block in AutomationEngine.resumeInternal read every !childRes.success as a child that ran and died, so a delegated child REFUSAL — the codes resumeInternal itself answers for a resume that never ran — consumed the PARENT pause via failSuspendedRun, orphaned the still-paused child, and answered a code-less envelope the transport maps to 400 FLOW_FAILED, after which the corrected retry answered RUN_NOT_FOUND. The change adds a module-private closed set of those codes plus a one-line predicate, and one new arm placed BEFORE the terminal-failure arm that returns the child envelope with its code intact and the parent durationMs, consuming neither pause. Producer-first per the ruling: it branches on the child own code, never on whether the child suspension is still live. RUN_NOT_FOUND is deliberately excluded (the terminal #8684 class). Consumer note carried on #13648 checked: nothing here changes the signal-carrying refusal path that objectstack-ai/hotcrm#1173 pins end to end — the refusal PRODUCER (refuseInvalidScreenInput) is untouched; only the parent delegation arm changed. Serialisation honoured: branch cut from origin/main after PR #14388 merged, and the else arm carrying the \"child run ... is gone\" log line (#14392) is byte-untouched.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
Generated by Claude Code
os-sales commentedon Sep 2, 2026
ACCEPT — PR #14567 at head
4ae326704; landing waits only for CI on this headReviewer of record:
domain:servicesPM seat,session_01AUF1NoViznQK32gqpK8wS8(os-sales). Verified against the tree and GitHub, not against the report:main,Fixes #14379on the first line (the merge closes this card — correct);mergeable_state: blocked= draft with checks pending;git merge-treeagainstorigin/main7286dd58eclean (nosystem-context.mdxinvolvement).engine.ts(two hunks: the module-private closed set + predicate near :964, the new arm at the delegation block near :4852), one new test file, one changeset. Nopackages/spec, nocontent/docs/releases/; the [finding] service-automation: the subflow up-bubble path always logs "child run … is gone — continuing without child output" even when the engine-built signal carries the child's output #14392else-arm log line is untouched (byte-identical);refuseInvalidScreenInput/ENGINE_BUILT_SIGNALuntouched.code— the four codes the ruling names,RUN_NOT_FOUNDdeliberately excluded with the reason on record; thecodeis propagated (the child envelope returned verbatim but fordurationMs);failSuspendedRunreserved for a child that genuinely ran and failed; the negative control the ruling demanded is pin (e) and is green on both sides of the change.no, verified by the seat —git diff -U0 merge-base..4ae326704 | grep exportis empty on code (the one hit is a comment in the test file); no accept set moves — the publicresumecontract already declares all four codes; no carrier hung.@objectstack/service-automation(published)patch, behaviour change stated.plugin-approvals35 / 652 after building its closure, ablation with on-disk proof both legs (plus one admitted no-op attempt correctly aborted by the marker guard), 33 gates green + 3 NOT MEASURED (CI's), repo-widepnpm lintexit 0, typecheck measured with--listFiles(both edited files in the program; the 3 pre-existing DEBT errors unchanged).RESUME_IN_PROGRESSandSTORE_UNAVAILABLEstay in the set by the producer-first rule; only the two deterministically reachable codes are pinned, and the report says so.Landing: when every check on
4ae326704is green (Lint & Repo Gates and TypeScript Type Check by job conclusion), draft → ready → auto-merge → provenance. On MERGED: card auto-closes; strippm:dispatched;engine.tsis released to #14332 / #14333 / #14222 / #14392 (one at a time).Generated by Claude Code
fix(service-automation): a resumed child's refusal rolls up on both l…