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
Commit e320e36
Browse filesBrowse the repository at this point in the historyBrowse files
fix(service-automation): a try_catch with no catch region and a subflow child that fails on resume carry the refusal to the caller (#27872)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DCyfbmgXaufoSckHTC2nNuF)_
domain:server#4 · os-elon-musk · session_016WV4cxM6XZhZWEMvCNT26B
Fixes#27743
Clause-②: no
Metadata-protocol: none
Skills-docs: none — no authorable metadata changes; this is runtime
carriage of members the spec already declares
## 维护者速读
- 改了什么:两条漏网路径现在也把结构化拒绝(`innerRefusal` 与 `userMessage`)带到流程调用方:没有 catch
区的 `try_catch`,以及先暂停、恢复后才遇到拒绝的 `subflow` 子流程(调用方恢复的是父运行 id 时)。
- 为什么改:这两处都把失败改写成一条新的失败,结构化成员被丢掉,调用方只拿到整串节点报错文字,界面无法显示简洁的拒绝提示。#27740 修了
`map` / `subflow` 的同步失败分支,这是它剩下的两条。
- 风险与回滚:很低。两处各加一行展开现有的携带函数,错误文字一字不变;没遇到拒绝时展开的是空对象。回滚即还原这一个 commit。
- 先看:`builtin/try-catch-node.ts`(无 catch
区的返回)、`src/run-resume.ts`(委派恢复腿的子流程失败返回)、`src/inner-refusal.ts`(头注释列出全部承载者)。
- 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run
src/flow-inner-refusal.test.ts`,预期 26 个测试全绿,其中新增 8 个(修复前 4 个红)。
- 需要你做的:无,席位落地。
## Measured at the public flow doors (`engine.execute` /
`engine.resume`)
The harness is the one the existing inner-refusal pins use: a real
`AutomationEngine`, the real CRUD / try_catch / map / subflow executors,
and a data engine throwing the platform's real error classes.
| Path | Door | Before | After |
|:--|:--|:--|:--|
| 1. `try_catch`, try region refused, no catch region | `execute` |
dropped: `innerRefusal` / `userMessage` absent, text only | carried |
| 1b. same, with a retry ladder (validation, then permission) |
`execute` | dropped | carries the LAST attempt's refusal
(`PERMISSION_DENIED`) |
| 2a. `subflow` child pauses, then its write is refused; caller resumes
the PARENT's run id (delegated leg, `run-resume.ts`) | `resume(parent)`
| dropped | carried |
| 2b. same, `subflow` → `subflow` two frames deep, resumed at the top id
| `resume(top)` | dropped | carried through both frames |
| 2c. same child, caller resumes the CHILD's run id (up-bubble leg) |
`resume(child)` | already carried (`stranded`, child's own result) |
unchanged, now pinned |
| 2d. `map` item pauses, then refused; resumed at the child's id (the
bubble leg a map item takes) | `resume(child)` | already carried |
unchanged, now pinned |
So the mechanism assumption held for path 1. For path 2 it held only for
the delegated `subflow` leg; the bubble leg (both `subflow` and `map`)
already reached the caller, because the caller of that leg holds the
child's own terminal result, which `resumeInternal`'s stranded exit
already brands.
## What changes
- **`try-catch-node.ts`:** each failed try attempt records
`readCarriedRefusal(err)` (replaced per attempt, so the last attempt
wins, the rule `retryExecution` uses). The no-catch return spreads it
beside the unchanged `error`. The engine's existing
`attachCarriedRefusal` then brands this node's throw like any data
node's.
- **`run-resume.ts`:** the delegated leg's `!childRes.success` return
spreads `carriedRefusalOfResult(childRes)`, exactly as the synchronous
`subflow` failure arm does since #27740.
- **`inner-refusal.ts`:** header updated: it now lists both new carriers
and the bubble leg, and names the one remaining non-carrier (a
`try_catch` whose catch region itself fails). Doc comment of
`carriedRefusalOfResult` names its new reader.
- No new vocabulary, no new member, no helper change, no `packages/spec`
edit.
## Tests (`src/flow-inner-refusal.test.ts`, +8)
1. `try_catch`: the region's refusal reaches the run, with code and
userMessage; the `error` chain is pinned byte for byte.
2. `try_catch` + retry: carries the last attempt's refusal, never an
earlier one's.
3. Control: a retry whose last attempt met a fault carries neither
member, even after an earlier refusal.
4. `subflow` delegated leg (resume at the parent id): code, userMessage,
and the `error` text.
5. `subflow` → `subflow` delegated twice: the refusal crosses both
frames.
6. `subflow` up-bubble leg (resume at the child id): plain pin, already
carried.
7. `map` item bubble leg (resume at the child id): plain pin, already
carried.
8. Control: a child that fails on resume with a fault carries neither
member on the delegated leg.
<details><summary>Red before the fix (tests first, fix absent)</summary>
```
× the region's refusal reaches the run — code and userMessage — and the text keeps its chain
× a retry ladder carries the LAST attempt's refusal, never an earlier one's
× subflow, resumed at the PARENT's run id (the delegated leg): the child's refusal reaches the caller
× subflow → subflow, resumed at the TOP run id: the refusal crosses both delegated frames
AssertionError: expected undefined to deeply equal { code: 'RESOURCE_CONFLICT', …(1) }
AssertionError: expected undefined to deeply equal { code: 'PERMISSION_DENIED', …(1) }
Tests 4 failed | 22 passed (26)
```
</details>
<details><summary>Ablation through scripts/ablation-replace.mjs (fix
committed first)</summary>
```
### ABLATE try-catch-node (anchor "...lastCarried," x1 -> x0, blob 5186063 -> 50139552)
× the region's refusal reaches the run — code and userMessage — and the text keeps its chain
× a retry ladder carries the LAST attempt's refusal, never an earlier one's
Tests 2 failed | 24 passed (26)
ok restored: blob == HEAD (5186063) and `git diff HEAD` is empty
### ABLATE run-resume (anchor "...carriedRefusalOfResult(childRes)," x1 -> x0, blob 51da355 -> a75e1d78)
× subflow, resumed at the PARENT's run id (the delegated leg): the child's refusal reaches the caller
× subflow → subflow, resumed at the TOP run id: the refusal crosses both delegated frames
Tests 2 failed | 24 passed (26)
ok restored: blob == HEAD (51da355) and `git diff HEAD` is empty
```
</details>
## Gates
- `@objectstack/service-automation`: full vitest 218 files / 2803 tests
green; `tsc --noEmit` and `check:test-typecheck` green.
- `dispatch-gates.mjs --commands` families plus `pnpm lint`: 61 derived;
60 exit 0, 1 NOT MEASURED (`check:dual-build-cjs-loads` exit 3, needs a
whole-tree build); `--ran` reconcile: 0 unrun. `pnpm lint` exit 0.
## Out of scope (reported, not changed)
- A `try_catch` whose CATCH region itself fails returns a failure built
from the handler's error and carries no member, so a refusal met by a
node inside the catch region is dropped too. Same file, outside the
claimed surface (the no-catch-region failure); the header names it as
the remaining non-carrier.
- Measured while probing path 2: resuming a run paused at a `map` node
at the PARENT's run id (rather than the item child's) re-runs the map,
advances past the parked item, and records the parent `completed` while
the item's child stays suspended. No refusal is involved, so nothing to
carry; it is a resume-semantics question for the seat.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_016WV4cxM6XZhZWEMvCNT26B
Co-authored-by: Claude <noreply@anthropic.com>
fix(service-automation): a refusal met inside a `try_catch` with no catch region, or by a `subflow` child that pauses and fails on resume, reaches the flow's caller as the structured refusal
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
A flow whose data node met a refusal (a permission, a validation rule, a duplicate, an app hook's declared refusal) reports it on the run's result as `innerRefusal` (`{ code, message }`) and the producer-marked `userMessage`, and `map` and `subflow` carry it across themselves. Two more paths built a new failure and lost both members, so the caller got only the node chain as text:
11
+
12
+
- A `try_catch` with no catch region. It now carries the refusal its last failed attempt met, so a retry ladder reports the last attempt's refusal, never an earlier one's.
13
+
- A `subflow` child that paused (a screen, an approval, a wait) and then met a refusal on resume, when the caller resumed the parent's run id. The parent's failure now carries the child's refusal, through nested subflows too.
14
+
15
+
Resuming the child's own run id (the leg a `map` item takes) already carried it, and is now pinned. A failure that met no refusal carries no member, as before, and the `error` text is unchanged.
0 commit comments