Skip to content

Commit 3e9065c

Browse files
os-warrenclaude
andauthored
fix(automation): publish $error on the throw arm before deciding whether the failure routes (#16302)
The engine's returned-failure arm rewrites the run-wide `$error` and `<nodeId>.error` unconditionally, and only then asks whether a `fault` edge may route the failure. The throw arm did both inside `if (faultEdge)`, so a thrown failure with no fault edge of its own left `$error` naming an earlier, unrelated failure — and a node inside a structured region never has a fault edge of its own, because the region's synthetic sub-flow carries only the region's own edges. The message and the code came from two different failures. Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y Co-authored-by: Claude <noreply@anthropic.com>
1 parent dee412b commit 3e9065c

5 files changed

Lines changed: 327 additions & 26 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
`$error` now names the most recent failure in a flow run, whichever way that failure arrived.
6+
7+
The automation engine has two failure arms. When a node FAILS BY RETURNING `{ success: false }`, the engine rewrote the run-wide `$error` (and `<nodeId>.error`) and then decided whether a `fault` edge could route it. When a node FAILED BY THROWING — a `timeoutMs` firing, a dying nested container, a thrown guard — it did both **inside** the `fault`-edge branch, so a thrown failure with no `fault` edge of its own left `$error` holding an earlier, unrelated failure's value.
8+
9+
A node inside a structured region never has a `fault` edge of its own: the region's synthetic sub-flow carries only the region's own edges. So every thrown failure inside a `try_catch`, `loop` body or other region hit this. The result was not a crash but a plausible-looking wrong value: **the message and the code came from two different failures**`{ code: 'DUPLICATE_RECORD', message: "Node 'mk' timed out after 20ms" }` — and a catch region branching on `{$error.code}` swallowed a store failure as "the row is already there" while the run reported success.
10+
11+
The throw arm now publishes `$error` and `<nodeId>.error` before deciding whether the failure routes, exactly as the returned-failure arm does. What a thrown failure publishes is `{ nodeId, message }`: there is no node result on that path, so no `output` and no classified `code` exist to carry — and that absence is the right answer for a throw rather than a reason to leave a stale `code` standing.
12+
13+
Routing is unchanged. A guard refusal that throws (ADR-0049's unscoped-run refusal, for one) is still un-routable, still fatal, and still reports its own message; the thrown value itself is rethrown exactly as caught.

packages/services/service-automation/src/builtin/create-record-duplicate-code.test.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -256,18 +256,20 @@ describe('#14419 (PR #14948 patch round 1) — a stale $error.code must not leak
256256
/**
257257
* The tier contract review's reproduction. `try_catch`'s catch region reads
258258
* `code` off the run-wide `$error` (necessarily — see the second describe
259-
* block above). But the engine only REWRITES `$error` when a failing node
260-
* RETURNS `{ success: false }`, or when it THROWS through a node with its
261-
* OWN `fault` edge (`engine.ts`'s `executeNode`, the throw arm). A node
262-
* inside a `try_catch`'s try region has no fault edge of its own — the
263-
* region's synthetic sub-flow carries only the region's own edges — so a
264-
* node that FAILS BY THROWING (a `timeoutMs` firing, here) leaves `$error`
265-
* exactly as an EARLIER, unrelated failure left it. Without the identity
266-
* guard in `try-catch-node.ts` (`innerError !== errorBefore`), that earlier
267-
* failure's `DUPLICATE_RECORD` code leaks onto this one — a store failure
268-
* misread as a duplicate and SWALLOWED, through a different door than
269-
* #14419's original bug but the exact same failure mode: the very thing
270-
* fence 4 of the ruling of record exists to rule out.
259+
* block above), so a stale `$error` becomes a store failure misread as a
260+
* duplicate and SWALLOWED — a different door than #14419's original bug,
261+
* the exact same failure mode, and the very thing fence 4 of the ruling of
262+
* record exists to rule out.
263+
*
264+
* Two independent things keep these two flows green, and this block pins the
265+
* OUTCOME rather than either mechanism: the identity guard in
266+
* `try-catch-node.ts` (`innerError !== errorBefore`), which is what closed
267+
* them when this block was written, and #14955's removal of the underlying
268+
* asymmetry — the engine's throw arm now publishes `$error` unconditionally,
269+
* so the thrown timeout here names itself and carries no `code`, instead of
270+
* leaving an earlier failure's `DUPLICATE_RECORD` standing. Either one alone
271+
* holds these two; `throw-arm-error-refresh.test.ts` carries the shape that
272+
* needs the second.
271273
*/
272274
function registerProbes(engine: AutomationEngine, seen: Array<{ code?: string; message?: string }>, ran: string[]): void {
273275
engine.registerNodeExecutor({

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

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -158,18 +158,25 @@ export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContex
158158
// Sink for THIS attempt's partial steps, filled by `runRegion` only if
159159
// the attempt throws (#7546).
160160
const attemptSteps: StepLogEntry[] = [];
161-
// #14948 review — the run-wide `$error` this attempt STARTS with. The
162-
// engine rewrites `$error` (a fresh object, see engine.ts's
163-
// `executeNode`) only when a failing node RETURNS `{ success: false }`,
164-
// or when it THROWS through a node that has its OWN `fault` edge — and
165-
// a node inside this region's synthetic sub-flow never has one (the
166-
// sub-flow carries only the region's own edges). So a node that FAILS
167-
// BY THROWING (a `timeoutMs` firing, a dying nested container, a
168-
// thrown guard) leaves `$error` exactly as an EARLIER failure left it.
169-
// Without this identity check, that earlier failure's `code` (e.g. a
170-
// sibling row's `DUPLICATE_RECORD`) leaks onto an unrelated later
171-
// failure's binding — a store failure misread as a duplicate through
172-
// the very door this card exists to close.
161+
// #14948 review — the run-wide `$error` this attempt STARTS with, kept
162+
// as the outer bound on what this attempt is allowed to claim as its
163+
// own failure.
164+
//
165+
// It was load-bearing on its own until #14955: the engine's throw arm
166+
// used to rewrite `$error` only for a node with its OWN `fault` edge,
167+
// and a node inside this region's synthetic sub-flow never has one (the
168+
// sub-flow carries only the region's own edges), so a node that FAILED
169+
// BY THROWING left `$error` exactly as an EARLIER failure had left it —
170+
// and that earlier failure's `code` (a sibling row's
171+
// `DUPLICATE_RECORD`, say) leaked onto an unrelated later failure's
172+
// binding, a store failure misread as a duplicate. #14955 made the
173+
// throw arm publish unconditionally, so a thrown node failure now names
174+
// itself here and carries no `code` of its own.
175+
//
176+
// The check stays because a throw is not always a NODE failure: a
177+
// durable pause refused inside a region, a missing region entry, or
178+
// anything else `runRegion` raises before reaching a node never touches
179+
// `$error` at all, and the stale value must not be claimed then either.
173180
const errorBefore = variables.get('$error');
174181
try {
175182
// #1479: surface the successful try region's steps.

packages/services/service-automation/src/engine.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7877,6 +7877,40 @@ export class AutomationEngine implements IAutomationService {
78777877
steps.push(...carriedSteps);
78787878
}
78797879

7880+
// #14955 — publish the failure BEFORE deciding whether it
7881+
// routes, exactly as the returned-failure arm below does.
7882+
//
7883+
// This write used to sit inside `if (faultEdge)`, and no reason
7884+
// for that was ever recorded: both arms were written in one
7885+
// commit, and only this one conflated "publish the failure" with
7886+
// "route the failure" — the arm below already separated them.
7887+
// The consequence was invisible rather than loud. A node inside a
7888+
// region never has a `fault` edge of its own (the region's
7889+
// synthetic sub-flow carries only the region's own edges — see
7890+
// {@link AutomationEngine.runRegion}), so EVERY thrown failure
7891+
// inside a region left `$error` naming an earlier, unrelated
7892+
// failure. The first reader that cared about `$error`'s freshness
7893+
// — `try_catch`'s `code` binding (#14419) — met it immediately
7894+
// and bound a message and a code that came from two different
7895+
// failures: `{ code: 'DUPLICATE_RECORD', message: "Node 'mk'
7896+
// timed out after 20ms" }`, swallowed by a catch region reading
7897+
// it as "the row is already there", the run reporting success.
7898+
//
7899+
// `{ nodeId, message }` and nothing more: there is no
7900+
// `NodeExecutionResult` on this path, so no `output` and no
7901+
// classified `code` exist to carry — and that absence is the
7902+
// correct answer for a throw, not a reason to leave a stale
7903+
// `code` standing. This is what the documented contract already
7904+
// promised — `{$error}` names the most recent failure
7905+
// (`content/docs/automation/flows.mdx`) — and now holds.
7906+
//
7907+
// Publishing is not routing: the guard-refusal rule below is
7908+
// untouched and still decides, alone, which failures a `fault`
7909+
// edge may carry. Nor is the thrown value touched — `execErr` is
7910+
// rethrown below exactly as caught.
7911+
variables.set('$error', { nodeId: node.id, message: errMsg });
7912+
this.setNodeError(variables, node.id, errMsg);
7913+
78807914
// #3863 — a guard that THROWS is as un-routable as one that
78817915
// returns: `UnscopedRunDataAccessError` (ADR-0049/#1888) reports
78827916
// that the metadata would run unscoped, and rerouting it would
@@ -7885,8 +7919,6 @@ export class AutomationEngine implements IAutomationService {
78857919
? undefined
78867920
: flow.edges.find(e => e.source === node.id && e.type === 'fault');
78877921
if (faultEdge) {
7888-
variables.set('$error', { nodeId: node.id, message: errMsg });
7889-
this.setNodeError(variables, node.id, errMsg);
78907922
const faultTarget = flow.nodes.find(n => n.id === faultEdge.target);
78917923
if (faultTarget) {
78927924
await this.executeNode(faultTarget, flow, variables, context, steps);

0 commit comments

Comments
 (0)