Skip to content

fix(service-automation): a resumed child's refusal rolls up on both legs — delegated resume and up-bubble - #19158

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-18714-resumed-leg-refusal-rollup
Sep 19, 2026
Merged

huangyiirene merged 3 commits into
mainfrom
claude/issue-18714-resumed-leg-refusal-rollup

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #18714

Clause-②: no

A child flow that durably PAUSES and only then refuses reached its parent on two resumed legs, and neither had an arm for it. The triage instruction on the card is verbatim 「⛔ 不要把它折进 PR #18706 …… 另起一张 PR,并让两条腿各有一条能变红的钉」 — so this is its own PR, and the two legs carry two independently reddening pins.

The two legs, and why they need two pins

They fail differently, which is the whole reason one "a refusal is handled" assertion would not do:

leg measured on origin/main before this change class
Delegated resumeengine.resume(parentRunId) parent answers { success: true, successMessage: … }, parent run row records completed, the node downstream of the subflow runs refusal LOST, fail-open
Up-bubbleengine.resume(childRunId) child row records refused correctly; parent stays paused and stays in listSuspendedRuns() indefinitely run LEAKED

The delegation block tested only paused and !success; a refused child is neither, because finishRefusedRun answers { success: true, status: 'refused' }a refusal is a successful evaluation that says no. And bubbleToParent was called on the completion path alone, so a child resumed to a refusal resolved exactly one of the two runs it is responsible for.

Neither leg is a regression of #18110 / #18555. That delivery named the two executors and matched its ruling exactly; its own changeset files this card for the remaining half, naming the resumed leg as 「the one a screen flow actually takes」.

The mechanism

  • Each leg records the child's refusal into one local, and one throw site inside the resume's traversal try raises the engine's existing internal refusal signal. The refusal therefore leaves through the same finishRefusedRun chokepoint every other producer already uses. ⛔ Deliberately not a second terminal exit per leg — this file's history is a list of outcomes that became a function of which route a run took.
  • The throw site sits past the consumption (claimAdvance / forgetSuspendedRun) and before the traversal: the parent's own pause is consumed exactly as on every other resume exit, so the terminal row and the pause can never disagree, and nothing downstream of the awaiting node runs.
  • The parent's terminal row reads refused, carrying the child's already-rendered refusalMessage verbatim, with its own successMessage silent. ⛔ Not failed: a refusal must not consume retry budget, must not be routable by a fault edge and must not be counted in nodes[].failures.
  • The up-bubble arm genuinely resumes the parent (with the refusal as its own argument, ⛔ never folded into the resume signal — that map is the parent's variables, and a refusal is control flow), so chains of any depth resolve by the same induction completions already rely on. ⛔ Not a direct ancestor walk like the failure cascade's, which records ancestors failed — the wrong word here.
  • The child's Surface flow run summaries (selected / acted / skipped) — a scheduled flow that does nothing is currently indistinguishable from one with nothing to do #4354 rollup survives on both legs, for the same reason it survives on the synchronous one.

Clause-② — why no

Nothing published moves. Both arms are inside AutomationEngine's private resumeInternal / bubbleToParent; the one new type (ChildRunRefusal) is module-private and not barrel-exported. No schema key, no closed-set member, no export, no registry entry. ⭐ In particular no new error code and no ERROR_CODE_LEDGER / StandardErrorCode entry is minted — the refusal is named by the existing internal signal type and the published refused status (#15788), which is the same call the sibling card #18881 made and an at-tier review confirmed. Zero packages/spec.

#18112 — read before choosing a mechanism, and this stays outside it

#18112's ruling deliberately left the region/rethrow territory closed: option B not implemented, no container taught to rethrow. This change teaches no container anything. It adds no arm to runRegion, to try_catch, to parallel or to any container executor; it touches only the resume machinery's own two seams, which are outside every region body by construction — a region body runs synchronously inside the enclosing run and cannot carry a durable pause at all (#18881's whole premise). So there is nothing here for a container to rethrow or to swallow.

The #19140 adjacency, checked

Re-measured on this branch: isRegionSuspensionRefusal has 3 sites in engine.ts (import, the one-refusal-one-failure suppression, the inner-boundary rethrow) and none is on either resume leg. The two predicates are structurally disjoint — the region refusal is branded with a registered Symbol.for on an Error subclass, the flow refusal is a non-Error sentinel carrying __flowRefused — so neither can be mistaken for the other. A run that resumes INTO a structured region and meets FlowRegionSuspensionRefusalError still falls to the generic failure arm and is failed, which is #18881's intended outcome; this change does not intercept it. ⇒ the resume legs do not need to handle it, measured rather than assumed.

Verification

mutation result
delete the delegated-leg detection 4 leg-1 assertions red (expected undefined to be 'refused'; [ 'child-work', 'downstream' ] where [ 'child-work' ] was expected) · all 5 leg-2 assertions and both controls green
delete the up-bubble of the refusal 3 leg-2 assertions red (expected 'paused' to be 'refused'; hasSuspendedRunexpected true to be false) · all 4 leg-1 assertions and both controls green

Two distinct failure signatures, each reachable only through its own arm — ⛔ not one measurement restated. Both restore legs proved blob == HEAD and an empty git diff HEAD.

Acceptance notes

  • Two of the leg-2 assertions are measured green on both sides of the ablation and are annotated in the file as such, so a reader never mistakes them for pins: downstream nodes do NOT run holds against the defect too (a parent that is never resumed also never walks on) and is kept as the pin on the WRONG fix — bubbling this refusal as a completion; and the child's own caller is told the truth about the CHILD is an invariance pin on the half a fix here could break.
  • Noted, not filed: the delegated-resume block special-cases the subflow: correlation only, so a map parent reaches its child's outcome exclusively through the up-bubble. That is consistent and covered by leg 2, and the asymmetry is a shape of the two node types rather than a defect. Successor: no PR or person is known to be heading for this seam.
  • Noted, not filed: bubbleToParent's per-outcome [convention] best-effort 降级导致"看起来正常、实则不持久"时不应记 warn——把 #4460 的点状修复定成规则 #4632 grading is unreached on a refusal, because a refused parent answers success: true. That is correct — a refusal is not a degradation — but it means the stranded strand-recording arm is exercised only by a parent that fails downstream of a refusal, which the existing subflow-bubble-strand-log-level pin already drives from the completion side. Successor: no PR or person is known to be heading for this seam.
  • No label writes were made from here: the dispatch budget is the report comment and this PR. If needs:contract-review or a size label is owed, it is the seat's to apply.

🤖 Generated with Claude Code


Generated by Claude Code

The delegated-resume block tested only `paused` / `!success`, so a child
that paused and then refused fell through the ordinary success exit and the
parent recorded `completed` with its downstream node run. `bubbleToParent`
was called on the completion path alone, so a child resumed to a refusal left
its parent parked in `listSuspendedRuns()` forever.

Both legs now record the refusal and hand it to one throw site inside the
resume traversal, past the consumption and before the traversal, so the run
terminates through the existing `finishRefusedRun` chokepoint.

Claude-Session: https://claude.ai/code/session_019hBqDVrwbijUCoK9qsss2E
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

2 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6819dcbd610397ded7a1533dbedf8a481d20fbdfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from de0c11fc31351ee9cf20ace5ab23210f1803b987 — the merge of head b2203ba3b726f624eb4b84bd8aec3332a0a0b11c into base 6819dcbd610397ded7a1533dbedf8a481d20fbdf, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin de0c11fc31351ee9cf20ace5ab23210f1803b987 && git checkout de0c11fc31351ee9cf20ace5ab23210f1803b987
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6819dcbd610397ded7a1533dbedf8a481d20fbdf b2203ba3b726f624eb4b84bd8aec3332a0a0b11c && git checkout -B drift-repro 6819dcbd610397ded7a1533dbedf8a481d20fbdf && git merge --no-ff b2203ba3b726f624eb4b84bd8aec3332a0a0b11c

node scripts/docs-audit/affected-docs.mjs --json 6819dcbd610397ded7a1533dbedf8a481d20fbdf

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6819dcbd610397ded7a1533dbedf8a481d20fbdf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@huangyiirene
huangyiirene marked this pull request as ready for review September 19, 2026 01:31
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit 97466dd Sep 19, 2026
36 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-18714-resumed-leg-refusal-rollup branch September 19, 2026 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants