Skip to content

Commit 4a0141c

Browse files
claude[bot]claude
andauthored
fix(automation): carry both regions' steps when a try_catch catch region fails (#14813)
`try_catch` returns a failure from three sites. #14184 taught the engine's returned-failure branch to fold `childSteps` and taught the no-`catch` producer to supply them; the `catch`-present-and-failing return was left unfolded and still discarded the whole step record. It is the worst of the three for an operator, because two regions ran: the try region may have written rows before it failed and the handler may have written more before IT failed, yet the run log kept a step for neither, so the #4354 summary reported `acted: 0` over writes that had landed. The catch region now gets the same `partialSteps` sink the try region already had (`runRegion`'s fifth argument) and the failing return carries `[...failedAttemptSteps, ...catchAttemptSteps]` — failed try attempts first, matching the successful-catch return's ordering. `runRegion`'s tagger already supplies `regionKind` on its failure path, so no tagging is added here and no engine change is needed. The pin that recorded the old boundary ("carries no try steps") is inverted in place with its comment rewritten to say what it used to assert and what moved it, rather than deleted. Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd288b4 commit 4a0141c

3 files changed

Lines changed: 165 additions & 13 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(automation): a `try_catch` whose `catch` region itself fails now keeps the step record of both regions
6+
7+
`try_catch` returns a failure from three sites. #13803 taught the engine to fold
8+
a dying container's carried steps off the THROW channel, #14184 taught its
9+
returned-failure branch (`if (!result.success)`) to do the same, and #14184 also
10+
taught the first producer — a `try_catch` with no `catch` region — to supply
11+
them. The second producer was left unfolded: when a `catch` region is present
12+
and the handler itself fails, the return dropped `childSteps` entirely.
13+
14+
That is the same defect one path over, and the worst of the three for an
15+
operator, because TWO regions ran. The try region may have written rows before
16+
it failed; the handler may have written more before IT failed; the run log kept
17+
a step for neither, so the run summary folded over that log reported `acted: 0`
18+
over writes that had genuinely landed. `acted: 0` on a failed run reads as
19+
"nothing happened, safe to re-run", which for a non-idempotent region invites
20+
double-execution.
21+
22+
Closing it needed the half that was genuinely missing rather than the available
23+
one: the failed try attempts were already in scope, but the catch region ran
24+
without a `partialSteps` sink, so when the handler threw, the handler's own
25+
completed steps unwound with the stack. The catch region now receives the same
26+
sink the try region already had (`runRegion`'s fifth argument), and the failing
27+
return carries `[...failedTryAttempts, ...catchAttempts]` — failed try attempts
28+
first, because they happened first, which is the ordering the successful-catch
29+
return has always used. `runRegion`'s existing tagger supplies `regionKind:
30+
'try'` / `'catch'` and `parentNodeId` on its failure path as well as its
31+
success path, so the two halves stay distinguishable in the log.
32+
33+
Additive to the RECORD only. This return already reported failure with the same
34+
error text, already produced a `NODE_FAILURE` step, already set `$error` and was
35+
already routable by a `fault` edge; none of that moves, and neither does the
36+
successful-catch path or the retry/throw semantics. No engine change was needed
37+
— the fold that reads these steps has been in place since #14184.

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

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,23 @@ export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContex
161161
// The try region (and any retries) failed. Run the catch handler if present.
162162
if (catchRegion != null) {
163163
variables.set(errorVariable, { nodeId: node.id, message: lastError });
164+
// #14222: sink for the catch region's OWN partial steps, filled by
165+
// `runRegion` only if the handler itself throws. Without it the catch
166+
// region's completed steps unwound with the stack exactly as the try
167+
// region's did before #7546 — see the failing-catch return below.
168+
const catchAttemptSteps: StepLogEntry[] = [];
164169
try {
165170
// #1479: surface the catch handler region's steps.
166-
const catchSteps = await engine.runRegion(catchRegion, variables, ctxOrEmpty, {
167-
parentNodeId: node.id,
168-
regionKind: 'catch',
169-
});
171+
const catchSteps = await engine.runRegion(
172+
catchRegion,
173+
variables,
174+
ctxOrEmpty,
175+
{
176+
parentNodeId: node.id,
177+
regionKind: 'catch',
178+
},
179+
catchAttemptSteps,
180+
);
170181
return {
171182
success: true,
172183
output: { attempts: maxRetries + 1, caught: true, error: lastError },
@@ -177,7 +188,34 @@ export function registerTryCatchNode(engine: AutomationEngine, ctx: PluginContex
177188
};
178189
} catch (catchErr) {
179190
const catchMsg = catchErr instanceof Error ? catchErr.message : String(catchErr);
180-
return { success: false, error: `try_catch '${node.id}': catch region failed — ${catchMsg}` };
191+
// #14222 — the THIRD returned-failure path, and the last one still
192+
// discarding its record. #13803 taught the engine to fold a dying
193+
// container's steps off the THROW channel and #14184 taught the
194+
// returned-failure branch the same, but only the no-`catch` producer
195+
// was taught to supply them. This return is the worst of the three
196+
// for an operator, because TWO regions ran: the try region may have
197+
// written rows before it failed, the handler may have written more
198+
// before IT failed, and the run log kept a step for neither — so the
199+
// #4354 summary folded over that log reported `acted: 0` over writes
200+
// that had genuinely landed. `acted: 0` on a failed run reads as
201+
// "nothing happened, safe to re-run".
202+
//
203+
// Ordering mirrors the successful-catch return directly above: the
204+
// failed try attempts come FIRST because they happened first, then
205+
// whatever the handler got through. `runRegion` has already tagged
206+
// both sets (`parentNodeId`, `regionKind: 'try'` / `'catch'`) on its
207+
// failure path as well as its success path, so the two halves stay
208+
// distinguishable in the log without anything being tagged here.
209+
//
210+
// Additive to the RECORD only: this return already reported failure
211+
// with this error text, already produced a `NODE_FAILURE` step and
212+
// was already routable by a `fault` edge. No engine change — the
213+
// #14184 fold on `if (!result.success)` is already the reader.
214+
return {
215+
success: false,
216+
error: `try_catch '${node.id}': catch region failed — ${catchMsg}`,
217+
childSteps: [...failedAttemptSteps, ...catchAttemptSteps],
218+
};
181219
}
182220
}
183221

packages/services/service-automation/src/builtin/try-catch-returned-failure-steps.test.ts

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,19 @@ import { registerLogicNodes } from './logic-nodes.js';
5050
* contents and the same fault-edge routing. This is a record fix; it does not
5151
* touch accept/reject. The nesting group pins the other risk the new fold
5252
* introduces — that a carried step could now reach the log twice.
53+
*
54+
* ## The third returned-failure path (#14222)
55+
*
56+
* `try_catch` returns failure from THREE sites, and #14184 taught only one of
57+
* them (no `catch` region) to carry its steps. The second — a `catch` region
58+
* that itself fails — kept discarding the record, and this file pinned that as
59+
* current behaviour rather than endorsing it. #14222 closed it by giving the
60+
* catch region the same `partialSteps` sink the try region already had, so the
61+
* failing-catch return now carries `[...failedTryAttempts, ...catchAttempts]`.
62+
* That pin is INVERTED in place below, comment and all: what it used to assert
63+
* is written out there, because the boundary it recorded is what makes the
64+
* change legible. (The third site is the config-parse refusal, where nothing
65+
* ran and there is no record to carry.)
5366
*/
5467

5568
function silentLogger(): any {
@@ -322,7 +335,7 @@ describe('#14184 a no-catch try_catch keeps the record of the writes its try reg
322335
expectNeverUnderReports(res.summary?.acted);
323336
});
324337

325-
it('a failing `catch` region still fails with the catch error and carries no try steps', async () => {
338+
it('a failing `catch` region fails with the catch error AND carries both regions\' steps', async () => {
326339
const { res, record } = await run({
327340
try: WRITE_WRITE_BOOM,
328341
catch: { nodes: [{ id: 'handler', type: 'boom', label: 'Handler' }], edges: [] },
@@ -333,13 +346,77 @@ describe('#14184 a no-catch try_catch keeps the record of the writes its try reg
333346
"Node 'guard' failed: try_catch 'guard': catch region failed — "
334347
+ "Node 'handler' failed: boom: at least one recipient is required",
335348
);
336-
// A DIFFERENT return, and this card does not touch it: it still carries
337-
// no `childSteps`, so the log keeps no try-region steps. That is the same
338-
// defect one path over and it is filed as #14222, not endorsed here —
339-
// closing it needs a `partialSteps` sink for the catch region, which is a
340-
// new seam rather than a mirror of this change. Whoever fixes #14222
341-
// updates this pin deliberately.
342-
expect((record?.steps ?? []).filter(s => s.regionKind === 'try')).toHaveLength(0);
349+
350+
// INVERTED by #14222 — and the record of what it used to say is the point.
351+
// Until then this case asserted the OPPOSITE:
352+
//
353+
// expect((record?.steps ?? []).filter(s => s.regionKind === 'try'))
354+
// .toHaveLength(0);
355+
//
356+
// with a comment saying that was a DIFFERENT return which #14184 did not
357+
// touch: it carried no `childSteps`, so the log kept no try-region steps,
358+
// and closing that gap needed a `partialSteps` sink for the catch region
359+
// — a new seam rather than a mirror of #14184's change. The assertion was
360+
// a deliberate record of the boundary #14184 stopped at, never an
361+
// endorsement, and it named #14222 as the card that would move it.
362+
//
363+
// #14222 moved it. It added the sink (the fifth `runRegion` argument the
364+
// try region already received), and the triage ruling that closed it is a
365+
// restore-invariant: the run log must carry every step that ran,
366+
// whichever region ran it. So the pin now reads the other way — and reads
367+
// the full shape rather than just presence: ordering is failed try
368+
// attempts FIRST (they happened first), which is the same rule the
369+
// successful-catch return has always used, and `runRegion`'s own tagger
370+
// supplies `regionKind` on its failure path as well as its success path.
371+
const grouped = (record?.steps ?? [])
372+
.filter(s => s.parentNodeId === 'guard')
373+
.map(s => `${s.regionKind}:${s.nodeId}:${s.status}`);
374+
expect(grouped).toEqual([
375+
'try:w1:success',
376+
'try:w2:success',
377+
'try:bang:failure',
378+
'catch:handler:failure',
379+
]);
380+
});
381+
382+
it('a `catch` region that writes before failing reports the writes from BOTH regions', async () => {
383+
const { res, record } = await run({
384+
try: WRITE_WRITE_BOOM,
385+
catch: {
386+
nodes: [
387+
{ id: 'cw1', type: 'write', label: 'Compensating write' },
388+
{ id: 'handler', type: 'boom', label: 'Handler' },
389+
],
390+
edges: [{ id: 'c1', source: 'cw1', target: 'handler' }],
391+
},
392+
});
393+
394+
// The shape the card calls the worst of the three for an operator: TWO
395+
// regions ran and both wrote before failing. Three rows are in the store.
396+
expect(res.success).toBe(false);
397+
expect(written).toEqual(['w1', 'w2', 'cw1']);
398+
expect(realWrites()).toBe(3);
399+
expectNeverUnderReports(res.summary?.acted);
400+
expect(res.summary?.acted).toBe(realWrites());
401+
402+
// This is the half the sink adds. The try region's steps alone — the
403+
// one-liner #14184 could have written here — would leave `cw1`
404+
// unrecorded and `acted` stuck at 2 over three writes that landed.
405+
const grouped = (record?.steps ?? [])
406+
.filter(s => s.parentNodeId === 'guard')
407+
.map(s => `${s.regionKind}:${s.nodeId}:${s.status}`);
408+
expect(grouped).toEqual([
409+
'try:w1:success',
410+
'try:w2:success',
411+
'try:bang:failure',
412+
'catch:cw1:success',
413+
'catch:handler:failure',
414+
]);
415+
416+
// Two sinks feed one return now, so pin what that risks: each carried
417+
// step still reaches the log exactly once.
418+
const steps = record?.steps ?? [];
419+
expect(new Set(steps).size).toBe(steps.length);
343420
});
344421
});
345422

0 commit comments

Comments
 (0)