Skip to content

Commit e320e36

Browse 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>
1 parent e655361 commit e320e36

5 files changed

Lines changed: 314 additions & 9 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
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.

‎packages/services/service-automation/src/builtin/try-catch-node.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { parseNodeConfig } from './parse-config.js';
99
import { currentLoopIteration } from './loop-frame.js';
1010
import { isRegionSuspensionRefusal } from '../region-suspension-refusal.js';
1111
import { attachPartialSteps } from '../partial-steps.js';
12+
import { readCarriedRefusal, type CarriedRefusal } from '../inner-refusal.js';
1213

1314
/**
1415
* `try_catch` built-in node — **structured try/catch/retry** (ADR-0031 §Decision 3).
@@ -150,6 +151,12 @@ export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContex
150151
// region could never distinguish "the row is already there" from any
151152
// other failure — the whole point of commit c5a7448d5.
152153
let lastErrorCode: string | undefined;
154+
// #27743 — the refusal the LAST failed attempt carried (`inner-refusal.ts`),
155+
// read off the brand the engine put on that attempt's throw. Replaced on
156+
// every failed attempt, so a retry ladder reports its last attempt's
157+
// refusal and never an earlier one's — `retryExecution`'s rule — and an
158+
// attempt that met no refusal leaves `{}`.
159+
let lastCarried: CarriedRefusal = {};
153160
const failedAttemptSteps: StepLogEntry[] = [];
154161
for (let attempt = 0; attempt <= maxRetries; attempt++) {
155162
if (attempt > 0) {
@@ -234,6 +241,7 @@ export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContex
234241
throw err;
235242
}
236243
lastError = err instanceof Error ? err.message : String(err);
244+
lastCarried = readCarriedRefusal(err);
237245
const innerError = variables.get('$error');
238246
// Only a `$error` that actually CHANGED (identity, not content —
239247
// two failures can legitimately share a message) belongs to THIS
@@ -365,9 +373,18 @@ export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContex
365373
// already routable by a `fault` edge. Adding `childSteps` moves none of
366374
// that — unlike the rejected "make `loop` swallow its throw and return"
367375
// shape (see `partial-steps.ts`), which would have changed all four.
376+
//
377+
// #27743 — and the try region's REFUSAL rides out the same way. This
378+
// return re-words the region's failure into a new one, and the brand on
379+
// the region's throw does not cross that, so a refusal met inside a
380+
// guarded region reached the run's caller as text only. Spread here, the
381+
// engine brands this node's throw like any data node's and the members
382+
// reach the terminal exit (`inner-refusal.ts`); `{}` when the last
383+
// attempt met none. The `error` text does not move.
368384
return {
369385
success: false,
370386
error: `try_catch '${node.id}': try region failed — ${lastError}`,
387+
...lastCarried,
371388
childSteps: failedAttemptSteps,
372389
};
373390
},

‎packages/services/service-automation/src/flow-inner-refusal.test.ts‎

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
* through does not lose them; a `fault` edge that handles the failure
2626
* leaves the run with none; a `map` item's or a `subflow` child's
2727
* refusal crosses that container (nested too), and a child that met no
28-
* refusal carries none;
28+
* refusal carries none; (#27743) a `try_catch` with no catch region
29+
* carries its last failed attempt's refusal, and a `subflow` child that
30+
* paused and fails on resume carries it to a caller holding either the
31+
* parent's run id or the child's (the leg a `map` item takes);
2932
* 4. ⛔ `error` is byte-identical to the composition it always had — the
3033
* diagnostic channel does not move.
3134
*
@@ -43,6 +46,7 @@ import { registerCrudNodes } from './builtin/crud-nodes.js';
4346
import { registerLoopNode } from './builtin/loop-node.js';
4447
import { registerMapNode } from './builtin/map-node.js';
4548
import { registerSubflowNode } from './builtin/subflow-node.js';
49+
import { registerTryCatchNode } from './builtin/try-catch-node.js';
4650
import { DuplicateRecordError, ValidationError } from '@objectstack/objectql';
4751
import { PermissionDeniedError } from '@objectstack/plugin-security';
4852
import { defineActionDescriptor } from '@objectstack/spec/automation';
@@ -498,3 +502,242 @@ describe('a `map` item and a `subflow` child carry the refusal across the contai
498502
expect(outer.error).toBe(`Node 'call' failed: subflow '${WRITER}' failed: ${childText(fault)}`);
499503
});
500504
});
505+
506+
describe('#27743 — a `try_catch` with no catch region carries the guarded refusal', () => {
507+
/** start → guard (try_catch, try = [mk], no catch) → end. */
508+
function guardedFlow(retry?: Record<string, unknown>): any {
509+
return {
510+
name: 'guarded', label: 'Guarded', type: 'autolaunched',
511+
nodes: [
512+
{ id: 'start', type: 'start', label: 'Start' },
513+
{
514+
id: 'guard', type: 'try_catch', label: 'Guard',
515+
config: {
516+
try: {
517+
nodes: [
518+
{ id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'crm_account', fields: { name: 'Acme' } } },
519+
],
520+
edges: [],
521+
},
522+
...(retry ? { retry } : {}),
523+
},
524+
},
525+
{ id: 'end', type: 'end', label: 'End' },
526+
],
527+
edges: [
528+
{ id: 'e1', source: 'start', target: 'guard' },
529+
{ id: 'e2', source: 'guard', target: 'end' },
530+
],
531+
};
532+
}
533+
534+
function guardedEngine(data: any): AutomationEngine {
535+
const engine = new AutomationEngine(makeLogger());
536+
const ctx = ctxWith(data);
537+
registerCrudNodes(engine, ctx);
538+
registerTryCatchNode(engine, ctx);
539+
return engine;
540+
}
541+
542+
it('the region\'s refusal reaches the run — code and userMessage — and the text keeps its chain', async () => {
543+
const err = markedHook();
544+
const engine = guardedEngine(refusingData(err));
545+
engine.registerFlow('guarded', guardedFlow());
546+
547+
const result = await engine.execute('guarded', { userId: 'u1' });
548+
549+
expect(result.status).toBe('failed');
550+
expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message });
551+
expect(result.userMessage).toBe(HOOK_USER_TEXT);
552+
expect(result.error).toBe(
553+
`Node 'guard' failed: try_catch 'guard': try region failed — `
554+
+ `Node 'mk' failed: create_record(crm_account) failed: ${err.message}`,
555+
);
556+
});
557+
558+
it('a retry ladder carries the LAST attempt\'s refusal, never an earlier one\'s', async () => {
559+
const first = validation();
560+
const last = permission();
561+
let calls = 0;
562+
const data = refusingData(undefined);
563+
data.insert = async () => { calls += 1; throw calls === 1 ? first : last; };
564+
const engine = guardedEngine(data);
565+
engine.registerFlow('guarded', guardedFlow({ maxRetries: 1, backoffMs: 0 }));
566+
567+
const result = await engine.execute('guarded', { userId: 'u1' });
568+
569+
expect(calls).toBe(2);
570+
expect(result.status).toBe('failed');
571+
expect(result.innerRefusal).toEqual({ code: 'PERMISSION_DENIED', message: last.message });
572+
});
573+
574+
it('control: a retry whose last attempt met a FAULT carries neither member, even after an earlier refusal', async () => {
575+
let calls = 0;
576+
const data = refusingData(undefined);
577+
data.insert = async () => {
578+
calls += 1;
579+
throw calls === 1 ? validation() : new Error('ECONNREFUSED: could not reach the store');
580+
};
581+
const engine = guardedEngine(data);
582+
engine.registerFlow('guarded', guardedFlow({ maxRetries: 1, backoffMs: 0 }));
583+
584+
const result = await engine.execute('guarded', { userId: 'u1' });
585+
586+
expect(calls).toBe(2);
587+
expect(result.status).toBe('failed');
588+
expect('innerRefusal' in result).toBe(false);
589+
expect('userMessage' in result).toBe(false);
590+
});
591+
});
592+
593+
describe('#27743 — a `map` item or `subflow` child that pauses and fails on resume', () => {
594+
/** start → hold (pause) → mk (create_record) → end — the child that fails after its pause. */
595+
const PAUSING_WRITER = 'hold_then_write';
596+
597+
function pausingEngine(err: unknown): AutomationEngine {
598+
const engine = new AutomationEngine(makeLogger());
599+
const ctx = ctxWith(refusingData(err));
600+
registerCrudNodes(engine, ctx);
601+
registerMapNode(engine, ctx);
602+
registerSubflowNode(engine, ctx);
603+
engine.registerNodeExecutor({
604+
type: 'hold',
605+
descriptor: defineActionDescriptor({ type: 'hold', version: '1.0.0', name: 'hold', supportsPause: true, resumeAuthority: 'any' }),
606+
async execute() { return { success: true, suspend: true }; },
607+
} as NodeExecutor);
608+
engine.registerNodeExecutor({
609+
type: 'seed',
610+
async execute(_node, variables) {
611+
variables.set('rows', [{ n: 1 }]);
612+
return { success: true };
613+
},
614+
} as NodeExecutor);
615+
engine.registerFlow(PAUSING_WRITER, {
616+
name: PAUSING_WRITER, label: 'Hold then write', type: 'autolaunched',
617+
nodes: [
618+
{ id: 'start', type: 'start', label: 'Start' },
619+
{ id: 'hold', type: 'hold', label: 'Hold' },
620+
{ id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'crm_account', fields: { name: 'Acme' } } },
621+
{ id: 'end', type: 'end', label: 'End' },
622+
],
623+
edges: [
624+
{ id: 'e1', source: 'start', target: 'hold' },
625+
{ id: 'e2', source: 'hold', target: 'mk' },
626+
{ id: 'e3', source: 'mk', target: 'end' },
627+
],
628+
} as any);
629+
return engine;
630+
}
631+
632+
function subflowFlow(name: string, flowName: string): any {
633+
return {
634+
name, label: name, type: 'autolaunched',
635+
nodes: [
636+
{ id: 'start', type: 'start', label: 'Start' },
637+
{ id: 'call', type: 'subflow', label: 'Call', config: { flowName } },
638+
{ id: 'end', type: 'end', label: 'End' },
639+
],
640+
edges: [
641+
{ id: 'e1', source: 'start', target: 'call' },
642+
{ id: 'e2', source: 'call', target: 'end' },
643+
],
644+
};
645+
}
646+
647+
function mapFlow(name: string, flowName: string): any {
648+
return {
649+
name, label: name, type: 'autolaunched',
650+
nodes: [
651+
{ id: 'start', type: 'start', label: 'Start' },
652+
{ id: 'query', type: 'seed', label: 'Seed' },
653+
{
654+
id: 'each', type: 'map', label: 'Each',
655+
config: { collection: { dialect: 'cel', source: 'rows' }, flowName, iteratorVariable: 'row' },
656+
},
657+
{ id: 'end', type: 'end', label: 'End' },
658+
],
659+
edges: [
660+
{ id: 'e1', source: 'start', target: 'query' },
661+
{ id: 'e2', source: 'query', target: 'each' },
662+
{ id: 'e3', source: 'each', target: 'end' },
663+
],
664+
};
665+
}
666+
667+
const childRunIdOf = (engine: AutomationEngine) =>
668+
engine.listSuspendedRuns().find(r => r.flowName === PAUSING_WRITER)?.runId;
669+
670+
it('subflow, resumed at the PARENT\'s run id (the delegated leg): the child\'s refusal reaches the caller', async () => {
671+
const err = markedHook();
672+
const engine = pausingEngine(err);
673+
engine.registerFlow('outer', subflowFlow('outer', PAUSING_WRITER));
674+
675+
const paused = await engine.execute('outer', { userId: 'u1' });
676+
expect(paused.status).toBe('paused');
677+
const childRunId = childRunIdOf(engine)!;
678+
const result = await engine.resume(paused.runId!, {});
679+
680+
expect(result.success).toBe(false);
681+
expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message });
682+
expect(result.userMessage).toBe(HOOK_USER_TEXT);
683+
expect(result.error).toBe(
684+
`subflow run '${childRunId}' (${PAUSING_WRITER}) failed: `
685+
+ `Node 'mk' failed: create_record(crm_account) failed: ${err.message}`,
686+
);
687+
});
688+
689+
it('subflow → subflow, resumed at the TOP run id: the refusal crosses both delegated frames', async () => {
690+
const err = permission();
691+
const engine = pausingEngine(err);
692+
engine.registerFlow('middle', subflowFlow('middle', PAUSING_WRITER));
693+
engine.registerFlow('outer', subflowFlow('outer', 'middle'));
694+
695+
const paused = await engine.execute('outer', { userId: 'u1' });
696+
expect(paused.status).toBe('paused');
697+
const result = await engine.resume(paused.runId!, {});
698+
699+
expect(result.success).toBe(false);
700+
expect(result.innerRefusal).toEqual({ code: 'PERMISSION_DENIED', message: err.message });
701+
});
702+
703+
it('subflow, resumed at the CHILD\'s run id (the up-bubble leg): the caller already gets the refusal', async () => {
704+
const err = markedHook();
705+
const engine = pausingEngine(err);
706+
engine.registerFlow('outer', subflowFlow('outer', PAUSING_WRITER));
707+
708+
const paused = await engine.execute('outer', { userId: 'u1' });
709+
expect(paused.status).toBe('paused');
710+
const result = await engine.resume(childRunIdOf(engine)!, {});
711+
712+
expect(result.success).toBe(false);
713+
expect(result.status).toBe('stranded');
714+
expect(result.innerRefusal).toEqual({ code: 'RESOURCE_CONFLICT', message: err.message });
715+
expect(result.userMessage).toBe(HOOK_USER_TEXT);
716+
});
717+
718+
it('map, its item resumed at the CHILD\'s run id (the bubble leg a `map` takes): the caller already gets the refusal', async () => {
719+
const err = validation();
720+
const engine = pausingEngine(err);
721+
engine.registerFlow('recalc', mapFlow('recalc', PAUSING_WRITER));
722+
723+
const paused = await engine.execute('recalc', { userId: 'u1' });
724+
expect(paused.status).toBe('paused');
725+
const result = await engine.resume(childRunIdOf(engine)!, {});
726+
727+
expect(result.success).toBe(false);
728+
expect(result.status).toBe('stranded');
729+
expect(result.innerRefusal).toEqual({ code: 'VALIDATION_FAILED', message: 'Amount must be positive' });
730+
});
731+
732+
it('control: a child that fails on resume with a FAULT carries neither member on the delegated leg', async () => {
733+
const engine = pausingEngine(new Error('ECONNREFUSED: could not reach the store'));
734+
engine.registerFlow('outer', subflowFlow('outer', PAUSING_WRITER));
735+
736+
const paused = await engine.execute('outer', { userId: 'u1' });
737+
const result = await engine.resume(paused.runId!, {});
738+
739+
expect(result.success).toBe(false);
740+
expect('innerRefusal' in result).toBe(false);
741+
expect('userMessage' in result).toBe(false);
742+
});
743+
});

‎packages/services/service-automation/src/inner-refusal.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,27 @@ export function refusalCarriedBy(err: unknown): CarriedRefusal {
124124
* data node's and the refusal reaches the top run however deep it was met.
125125
* The child's result is the only source: a child that met no refusal spreads
126126
* `{}`, and a `map` stops at its first failing item, so it carries that
127-
* item's. A `try_catch` with no catch region still builds a new error the
128-
* brand does not cross; the run's result then carries no member, which the
129-
* spec's "present only when a refusal was met" allows and which never
130-
* invents one.
127+
* item's.
128+
*
129+
* The other carriers (#27743), each measured at `execute` / `resume`:
130+
*
131+
* - A `try_catch` with NO catch region re-words its try region's failure into
132+
* a new returned failure too, and carries the members its LAST failed
133+
* attempt's throw carried (`readCarriedRefusal`), so a retry ladder reports
134+
* the last attempt's refusal and never an earlier one's.
135+
* - A `subflow` child that PAUSED and then fails on resume, resumed at the
136+
* parent's run id (the delegated leg in `run-resume.ts`): the parent's
137+
* re-worded failure carries the child's result's members
138+
* (`carriedRefusalOfResult(childRes)`), through every delegated frame.
139+
* - Resumed at the CHILD's run id instead (the up-bubble leg, and the only
140+
* leg a `map` item takes), the caller already holds the child's own
141+
* terminal result, which carries the members; the ancestors that leg fails
142+
* record no member, and no caller reads their result.
143+
*
144+
* Not a carrier: a `try_catch` whose CATCH region itself fails builds its
145+
* failure from the handler's error and carries no member; the run's result
146+
* then carries none, which the spec's "present only when a refusal was met"
147+
* allows and which never invents one.
131148
*/
132149
export const CARRIED_REFUSAL: unique symbol = Symbol.for(
133150
'objectstack.automation.carriedRefusal',
@@ -164,9 +181,10 @@ export function readCarriedRefusal(err: unknown): CarriedRefusal {
164181
/**
165182
* The members an attempt's RESULT carries — for `retryExecution`, whose
166183
* exhausted exit reports the last attempt's failure and must report that
167-
* attempt's refusal beside it, never an earlier attempt's — and for the
184+
* attempt's refusal beside it, never an earlier attempt's — for the
168185
* `subflow` / `map` failure arms, which carry their child run's refusal onto
169-
* the failure they return.
186+
* the failure they return, and for the delegated resume leg, which carries a
187+
* paused-then-failed child's refusal onto the parent's.
170188
*/
171189
export function carriedRefusalOfResult(result: AutomationResult): CarriedRefusal {
172190
return pickCarried(result);

0 commit comments

Comments
 (0)