Skip to content

fix(service-automation): a try_catch whose catch region fails carries the refusal it met - #27929

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-kwainq
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-kwainq

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

Fixes #27876

维护者速读

  • 改了什么:try_catch 的 catch 区域自身失败时,返回的失败现在带上 catch 区域遇到的拒绝(innerRefusal / userMessage),沿用无 catch 路径已有的 readCarriedRefusal 一行承载,无新词汇。
  • 为什么改:之前 catch 区域遇到的业务拒绝(校验规则、权限、重复、应用钩子)到达流程调用方时只剩文本,控制台无法显示业务消息;这是 service-automation: a refusal met inside a try_catch with no catch region, or by a map item / subflow child that pauses and fails on resume, may not reach the flow's caller as the structured refusal #27743 的 catch 区域那一半。
  • 风险与回滚:只新增两个可选成员,error 文本逐字不变;带的是 catch 区域自己的拒绝,绝不是它已处理的 try 区域的拒绝;catch 区域遇到故障时两个成员都不出现;回滚即还原本 PR 的单个提交。
  • 先看:packages/services/service-automation/src/builtin/try-catch-node.ts 中 catch 区域失败的 return(一行展开 + 注释)。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/flow-inner-refusal.test.ts,看 #27876 下三条用例通过(29/29)。
  • 需要你做的:审阅并合并即可;无规格变更、无治理面路径、无需契约复核。

Clause-②: no
Metadata-protocol: none

What changes

  • packages/services/service-automation/src/builtin/try-catch-node.ts: the catch-region failure return spreads readCarriedRefusal(catchErr), the same carriage the no-catch return already spreads (...lastCarried). The carried members are the ones the catch region's own throw carried. The error text and childSteps are unchanged.
  • packages/services/service-automation/src/inner-refusal.ts: header only. The carrier list now names this path, and the paragraph that called it "Not a carrier" is removed.
  • packages/services/service-automation/src/flow-inner-refusal.test.ts: three pins through engine.execute (try = [create_record], catch = [create_record]):
    1. Both regions are refused by a validation rule (the card's measured case). The run carries innerRefusal: { code: 'VALIDATION_FAILED', message: 'Amount must be positive' } and no userMessage, and error keeps its chain byte for byte.
    2. The try region is refused by a permission and the catch region by an app hook's marked refusal. The run carries the catch region's RESOURCE_CONFLICT and its userMessage, never the try region's PERMISSION_DENIED.
    3. Control: the try region is refused and the catch region meets a fault. The run carries neither member.
  • .changeset/27876-try-catch-catch-region-carry-refusal.md: patch for @objectstack/service-automation.

Why

PR #27872 made a refusal met inside a try_catch with no catch region reach the flow's caller as the structured refusal. When the catch region itself failed, the return still re-worded the handler's failure into a new one. The engine's carried-refusal brand on the handler's throw does not cross that re-wording, so the caller got text only and a runner had no business message to show. This uses the same one-line carriage and adds no new vocabulary.

Verification

All readings are against objectstack-ai/objectstack at base 2a3dec2d, in worktree branch wip-27876.

  • Before (measured through engine.execute, without the fix): status: failed, error: "Node 'guard' failed: try_catch 'guard': catch region failed — Node 'b' failed: create_record(crm_account) failed: Amount must be positive", 'innerRefusal' in result === false, 'userMessage' in result === false.
  • After: the same error text byte for byte, plus innerRefusal: { code: 'VALIDATION_FAILED', message: 'Amount must be positive' }. userMessage stays absent because a validation rule marks none.
  • Red first: with the pins in place and no fix, flow-inner-refusal.test.ts had 2 failed and 27 passed (29). Pins 1 and 2 failed with expected undefined to deeply equal { code: … }. Pin 3, the control, passed, as a control should.
  • Ablation: I deleted the single spread line from the committed fix, and pins 1 and 2 turned red again (2 failed, 27 passed). Then I restored the file with git restore --source=HEAD --staged --worktree. Its git hash-object (09436a8a…) is the same before the ablation, after the restore, and in HEAD.
  • Package: @objectstack/service-automation pnpm test passed 218 files and 2806 tests. pnpm typecheck (tsc plus check:test-typecheck) exited 0. Root pnpm lint exited 0.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 59 commands for the four changed paths. I ran each one and recorded its exit code before any pipe. 58 exited 0. 1 is NOT MEASURED: pnpm check:dual-build-cjs-loads exited 3 with PREREQUISITE NOT MET because it reads every package's built dist/, which needs a whole-tree build that was not run here. --ran reconciliation: 59 derived, 58 run, 1 NOT-MEASURED, 0 UNRUN. The derivation is identical byte for byte when taken from the tree at origin/main f552fa81, which is 12 commits past the base, and none of those commits touch service-automation. Not measured locally: the CI-only steps the tool lists (Build Core, the Test Core shard plan, and the push-only Dogfood and Temporal jobs), the 4 families that take a workflow value, and the 11 families with a wide population.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL

… the refusal it met

The catch-region failure return re-words the handler's failure into a new
returned failure, and the engine's carried-refusal brand on the handler's
throw did not cross it, so a refusal met inside a catch region reached the
flow's caller as text only. Spread readCarriedRefusal(catchErr) onto that
return, the same carriage the no-catch return already uses. The carried
refusal is the catch region's own, never the try region's it handled; a
handler that met a fault carries neither member. The error text is unchanged.

Pinned in flow-inner-refusal.test.ts through engine.execute; the
inner-refusal.ts header's carrier list now names this path.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1Np3hU7RQnA13YZcL
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-automation/src/inner-refusal.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-automation/src/inner-refusal.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 7 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 d4064cc159ce0804091278eb964d8da8e05dec87 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4d1Np3hU7RQnA13YZcL

维护者速读

席位意见:ACCEPT。

席位核对

Head-sha: ca881be3e6cb4c1f23742cdac6e7dac23ecf6bcd

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/m tests tooling

Projects

None yet

1 participant