Skip to content

fix(service-automation): a durable suspension inside a structured region fails the run with a named refusal - #19140

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-18881-region-durable-suspension-refusal
Sep 19, 2026
Merged

huangyiirene merged 1 commit into
mainfrom
claude/issue-18881-region-durable-suspension-refusal

Conversation

@huangyiirene

@huangyiirene huangyiirene commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18881

Fixes #15646

Clause-②: no — this is a refusal being added, pulling runtime behaviour back to #3267's declared 禁. The flow accept set does not widen, and no export leaves this package: FlowRegionSuspensionRefusalError lives in a new internal module that src/index.ts does not re-export, exactly as guard-refusal.ts and partial-steps.ts do.

The defect, measured before anything was written

A node contained in an ADR-0031 structured region body — a loop body, a parallel branch, a try_catch try or catch region, at any depth — that durably suspends now FAILS the run with a named refusal carrying the region node, the suspending node and the sub-flow. runRegion already converted such a suspension, but into a plain Error, which is indistinguishable from a node that simply failed.

The card's own reproduction, loop { try_catch { map(pausing child) } }, on origin/main at c70581bc with only the new test file added:

 × the card's reproduction: `loop { try_catch { map(pausing) } }` fails, and the catch handler never sees it
   AssertionError: expected true to be false     // result.success
 × the error NAMES the region node, the suspending node and the sub-flow
   AssertionError: the given combination of arguments (undefined and string) is invalid
 × counts the refusal ONCE: `summary.failed = 1`, on the region node
   AssertionError: expected +0 to be 1
 × `loop { map(pausing) }` — the region node is the `loop`
   Expected: "sweep"
   Received: "durable pause inside a structured region (node 'per_cell') is not supported"

 Tests  6 failed | 2 passed (8)

Read that fourth line: even where the run DID fail, the sentence named neither the region nor the sub-flow. The try_catch case is worse — the enclosing container read the plain Error as "the try region failed", ran its catch handler, and the run finished completed with summary.failed = 0. The map's progress state (nodeId.$mapState) is written into the ENCLOSING scope, so the residue a contained refusal leaves is read back as progress by the next entry to the same node: iteration 2 saw started === collection.length, ran nothing, and reported success again.

What changed

No parse-time rule is added here. #18688 landed that half. ⛔ No new error.code: the closed ERROR_CODE_LEDGER (ADR-0112) lives in packages/spec, outside this card's declared file surface; the refusal is named by its type and its fields.

The closing keyword on #15646, and the round trip it took to get there

⭐ This section previously explained why the body said Part of #15646. It now says Fixes, and the reasoning is kept rather than deleted because the round trip is the record.

Ruling D's execution clause names the mechanism, verbatim: 「The spec half lands with Part of #15646; the runtime card's PR closes this card with Fixes #15646 once both are on main.」 Both halves exist: 78436637 (PR #18688) is an ancestor of origin/main and of this branch's base, and the parse-time refusal does not reach the card's reproduction — map and subflow are deliberately not judged by type there — so the runtime arm is live and every fixture in the new suite registers and runs.

What blocked the literal mechanism was a shipped gate. check:closing-target-claim refuses a PR closing a card whose thread carries no live Claim: naming that PR's head branch. #15646 carried three claims, all naming claude/issue-15646-region-pause-end-refusal, and its newest protocol event was a Release: (5730095126) — no live claim, none naming this branch. ⛔ A dispatched executor may not post a claim of its own to clear that, so the delivering dev shipped Part of and reported the conclusion as falsified. That was the correct call at its authority level.

The gate's own remedy 1 is a PM act, and the PM seat performed it: the domain:spec seat had already released this card's runtime half to domain:services in writing (「由 services 车道重新认领」), so the services seat claimed it on this branch (issuecomment-5737225992) and assigned itself. The claim is simply true — this card's remaining half really is in flight here.

Re-measured after the claim, with the gate's own prescribed invocation: check:closing-target-claim exits 0, reporting 「PR #19140 closes #18881, #15646, and each carries a Claim: whose Branch: line names claude/issue-18881-region-durable-suspension-refusal」.

⇒ the ruling is executed literally, via the gate's prescribed route. ⛔ Not a re-adjudication of the ruling, and ⛔ not an evasion of the gate — the gate exists to stop a second seat duplicating work on an unclaimed card, and a truthful claim serves that purpose.

Tests

All figures below were taken at bad6404b0, the final commit on this branch.

The new suitepackages/services/service-automation/src/region-durable-suspension-refusal.test.ts, 8 tests, every refusal case paired with a synchronous control:

pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 \
  src/region-durable-suspension-refusal.test.ts
 Test Files  1 passed (1)
      Tests  8 passed (8)

The whole package, which is what CI runs and the only scope that can see this class of breakage:

pnpm --filter @objectstack/service-automation test
 Test Files  139 passed (139)
      Tests  1660 passed (1660)

pnpm --filter @objectstack/service-automation typecheck
check:test-typecheck: OK — 0 file(s) / 0 error(s) / 0 pinned signature(s)

1652 + 8 = 1660. PR #18688's body measured this package at 138 files / 1652 tests, all passing on the tree this branch is cut from. This branch adds exactly one file and eight tests and lands at 139 / 1660, so nothing was lost, re-homed, skipped or quarantined. #15616's five tests, #15788's runtime end test and #16314's rollup suite are untouched in the diff and green in that run.

Reverse validation — two ablations, each mutated on disk through scripts/ablation-replace.mjs (anchor must hit, blob hash must change) and restored against HEAD. The package's own tests import their subject through relative specifiers (./engine.js), so the mutation is live from source with no dist on the path — which each ablation demonstrates by going red.

  1. The try_catch re-throw arm, the one that decides whether this card's defect exists:
ablation-replace: anchor "if (isRegionSuspensionRefusal(err)) {" x1 -> x0
ablation-replace: blob   c4ed5b307865 -> c3b44a8af269
  × the card's reproduction: `loop { try_catch { map(pausing) } }` fails …
  × the error NAMES the region node, the suspending node and the sub-flow
  × counts the refusal ONCE: `summary.failed = 1`, on the region node
 Tests  3 failed | 5 passed (8)
ablation-replace: ok restored: blob == HEAD (c4ed5b307865) and `git diff HEAD` is empty

The three loop / parallel cases and both controls stay GREEN under that mutation, which is the second reading it buys: the try_catch arm is precisely what closes the contained case, and the other two region kinds are closed by a different arm.

  1. The one-refusal-one-failure rule, because summary.failed = 1 is the assertion most at risk of being vacuously true:
ablation-replace: anchor "isRegionSuspensionRefusal(execErr) && execErr.regionNodeId !== node.id;" x1 -> x0
  × counts the refusal ONCE: `summary.failed = 1`, on the region node
    AssertionError: expected 2 to be 1
 Tests  1 failed | 7 passed (8)
ablation-replace: ok restored: blob == HEAD (53fee28ca7dd) and `git diff HEAD` is empty

2 is exactly the nesting-depth reading — the try_catch and the loop each recording the same event — that the suppression prevents.

Gates. Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths passed, reconciled with --ran:

Run reconciliation — 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run,
  0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3)

Two of the 59 first answered PREREQUISITE NOT MET (exit 3, ⛔ not a finding) because they read built output the whole tree has to supply — check:dual-build-cjs-loads and check:type-check-debt. Both were re-run after pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' (72/72 tasks) and both exit 0. check:plugin-teardown-shape --self-test first answered exit 3 on a pinned fixture commit this shallow checkout could not reach; after git fetch --unshallow it passes its 48 cases.

Lint — a declared narrowing, and a measured one. pnpm lint is eslint . --no-inline-config: a whole-repo scan whose broadest block is files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus NEVER_LINTED, read from eslint.config.mjs itself. Run instead over this diff's five lintable paths, counted from the linter's own --format json output: 5 files, 0 errors, 0 warnings. The narrowing excludes nothing, and that is a property of the config rather than an assumption: eslint.config.mjs never enables type-aware linting for ANY file — no parserOptions.project, no typed @typescript-eslint rules — stated verbatim at eslint.config.mjs:326 with its own positive-control measurement, so every rule is per-file and syntactic and no edit here can move the verdict on a file it does not contain.

⚠️ dispatch-gates reports its derivation as taken from a tree behind origin/main, with scripts/check-release-spec-changes.mjs and scripts/ts-parse.mjs changed across that range. Neither declares a packages/services population — check:spec-changes is spec-release-scoped and ts-parse.mjs is a parser library that declares no family — so the derived list is unchanged at 59. CI derives it again on the merge base.

Acceptance notes

Noted while reading, ⛔ not filed and ⛔ not fixed here — none is a reproducible defect, a contract violation or a metadata-authoring trap:


Generated by Claude Code

…ion fails the run with a named refusal

An ADR-0031 region body — a `loop` body, a `parallel` branch, a
`try_catch` try or catch region, at any depth — runs synchronously
inside the enclosing run and cannot park it on a durable pause.
`runRegion` already converted such a suspension, but into a plain
`Error`: indistinguishable from a node that simply failed, so an
enclosing `try_catch` read it as "the try region failed", ran its catch
handler, and the run reported success over a sweep that had processed
nothing.

Measured on `loop { try_catch { map(pausing child) } }` before this
change: `success: true`, run `completed`, `summary.failed = 0`, 0 of 10
child runs executed. The `map`'s progress state lives in the enclosing
scope, so the residue a contained refusal leaves is read back as
progress by the next entry to the same node.

`FlowRegionSuspensionRefusalError` carries the region node, the region
kind, the suspending node and the sub-flow as fields as well as in its
message, and is branded as a guard refusal so a `fault` edge cannot
route it. `try_catch` and `parallel` re-throw it instead of containing
it; `loop` already did. One refusal is one failure: the region node's
own frame records the step and publishes `{$error}`, enclosing frames
record nothing, so `summary.failed` counts the fault and not the
nesting depth.

Nothing changes for a region whose nodes complete synchronously — every
refusal case is pinned beside a synchronous control.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 17 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 aadea24b893661605af747abdd2cce9c036e300cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 23d7e010aadd0390c7492a2369a2d37b62092117 — the merge of head bad6404b085213ef0c4c08b947c1034162683808 into base aadea24b893661605af747abdd2cce9c036e300c, 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 23d7e010aadd0390c7492a2369a2d37b62092117 && git checkout 23d7e010aadd0390c7492a2369a2d37b62092117
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin aadea24b893661605af747abdd2cce9c036e300c bad6404b085213ef0c4c08b947c1034162683808 && git checkout -B drift-repro aadea24b893661605af747abdd2cce9c036e300c && git merge --no-ff bad6404b085213ef0c4c08b947c1034162683808

node scripts/docs-audit/affected-docs.mjs --json aadea24b893661605af747abdd2cce9c036e300c

⚠️ 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 aadea24b893661605af747abdd2cce9c036e300c → 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 18, 2026 23:38
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 554e928 Sep 19, 2026
43 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-18881-region-durable-suspension-refusal branch September 19, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment