Skip to content

Commit 190d200

Browse files
claude[bot]claude
andcommitted
fix(service-automation): a failed run is still answered in the declared shape when its own history write throws (#17562)
`execute()` and `executeWithoutRetry()` ended their node-failure `catch` with an unguarded `recordLog({ status: 'failed' })`. That `catch` IS the handler for node failures and there is no outer one, so a throw out of the history write escaped the method and left `execute()` a REJECTED PROMISE where `AutomationResult` is declared. What is lost is the SHAPE, not the verdict: the run really did fail, so nothing misleads an operator, but the transport's `status` arm is bypassed and `errorMessage` and `summary` never reach the caller — a 500-class throw for a run that had a perfectly good failure envelope waiting, with the node's own error text replaced by the history driver's. Reproduced with a control on the post-merge tree, the identical flow and node failure differing only in the store: store = SYNC-THROW -> {"kind":"threw","error":"run-history driver refused the terminal row"} store = HEALTHY (control) -> {"kind":"returned","status":"failed","error":"work blew up"} Guarded at each call site in the shape the resume path's failure arm already landed: report the swallowed failure once at `error` with its consequence and fix, recompute the summary with the same pure function. On the retry path the throw also used to take the remaining attempts with it; the budget now survives. No `catch` arm's meaning is widened. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 216b066 commit 190d200

3 files changed

Lines changed: 569 additions & 4 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
A run that genuinely failed is still answered in the declared shape when its own terminal run-history write throws (#17562)
6+
7+
`AutomationEngine.execute()` and `executeWithoutRetry()` each ended their node-failure `catch` with an unguarded `recordLog({ status: 'failed' })`. That `catch` **is** the handler for node failures and there is no outer one, so a throw out of the history write escaped the method entirely and left `execute()` a **rejected promise**, where its declared return type is an `AutomationResult`. This is the failure-arm half of the completion-path guard shipped just before it, and the same shape already landed on the resume path's failure arm in 17.4.0.
8+
9+
**What is lost is the shape, not the verdict.** The run really did fail, so nothing misleads an operator: there is no false `failed` and no double run. But a caller that branches on `{ success: false, status: 'failed' }` gets an exception instead, so the transport's `status` arm is bypassed and `errorMessage` (the author's failure text) and `summary` (how far the run got before dying) never arrive — a REST route or SDK caller sees a 500-class throw for a run that had a perfectly good failure envelope waiting, and the node's own error text is replaced by the history driver's.
10+
11+
Reproduced with a control, the identical flow and the identical node failure differing only in the store:
12+
13+
```
14+
store = SYNC-THROW -> {"kind":"threw","error":"run-history driver refused the terminal row"}
15+
store = HEALTHY (control) -> {"kind":"returned","status":"failed","error":"work blew up"}
16+
```
17+
18+
**What can throw there is a host surface, not in-repo code** — the same two statements the completion-path fix names: the default-on run-summary line `logger.info(line, meta)`, which calls a host-injected `Logger` and needs no store at all; and `store.recordTerminal(record)` throwing **synchronously**, before it returns a promise, which the `void write.catch(...)` beneath that call cannot see. Both stores shipped in this package are `async` and cannot do it, but `SuspendedRunStore` is an exported interface whose `recordTerminal` is optional, so a host store is unconstrained.
19+
20+
What changes:
21+
22+
- **Each failure-path history write is guarded at its own call site**, restoring the invariant that call's own documentation states: a history write must never block or break the run that produced it. The caller now receives the envelope it was always promised — `success: false`, `status: 'failed'`, the **node's** own text in `error`, the flow's `errorMessage`, and a `summary` recomputed by the same pure function `recordLog` runs first.
23+
- **The retry budget survives the loss.** On the retry path the throw used to reject out through the retry loop and `execute()` both, ending the run early; the remaining attempts now run as the author's policy says.
24+
- **The swallowed failure is reported once per abandoned write at `error`**, with the consequence and the fix in the first line: the run failed, its terminal row never landed, nothing retries it, and the caller *was* told the run failed so nothing needs re-driving. The thrown text rides the structured slot.
25+
26+
⛔ No `catch` arm's meaning is widened: the suspend arm, the input-schema refusal and the retry strategy branch are untouched, and a genuine node failure against healthy sinks is answered exactly as before.

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

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5049,7 +5049,45 @@ export class AutomationEngine implements IAutomationService {
50495049

50505050
// Record failed execution log
50515051
const durationMs = Date.now() - startTime;
5052-
const logged = this.recordLog({
5052+
// [#17562] THE RUN IS OVER AND IT FAILED, and this `catch` IS the
5053+
// handler for that failure — there is no outer one. So a throw out
5054+
// of THIS history write escaped `execute()` entirely and left it a
5055+
// REJECTED PROMISE, where its declared return type is an
5056+
// `AutomationResult`. The FAILURE-arm half of the guard #16274
5057+
// landed on the completion arm of this same method, and the same
5058+
// shape #15555 landed on {@link resumeInternal}'s failure arm.
5059+
//
5060+
// ⚠️ What is lost is the SHAPE, not the verdict — which is why this
5061+
// is a narrower defect than #16274's and still a contract
5062+
// violation. The run genuinely failed, so nothing misleads an
5063+
// operator: no false `failed`, no double run. But the caller that
5064+
// branches on `{ success: false, status: 'failed' }` gets an
5065+
// exception instead, so the transport's `status` arm (#9378) is
5066+
// bypassed and `errorMessage` (#9414) and `summary` (#4354) never
5067+
// arrive — a REST route or SDK caller sees a 500-class throw for a
5068+
// run that had a perfectly good failure envelope waiting, and the
5069+
// NODE's own error text is replaced by the history driver's.
5070+
//
5071+
// The two statements inside `recordLog` that reach here are the
5072+
// ones stated at the completion site above: the default-on
5073+
// run-summary line `this.logger.info(line, meta)` calling a
5074+
// HOST-INJECTED `Logger`, and `store.recordTerminal(record)`
5075+
// throwing SYNCHRONOUSLY — which the `void write.catch(...)`
5076+
// beneath that call cannot see, because it only ever observes a
5077+
// RETURNED promise's rejection.
5078+
//
5079+
// The guard restores the invariant `recordLog`'s own doc states —
5080+
// "a history write must NEVER block or break the run that produced
5081+
// it" — which this call was relied upon to keep and did not.
5082+
//
5083+
// ⛔ NOT a widening of this arm's meaning: the suspend arm above,
5084+
// the `InputSchemaViolationError` refusal below and the
5085+
// `strategy: 'retry'` branch are all untouched, a genuine node
5086+
// failure still records `failed` with the node's own text, and the
5087+
// retry budget is unchanged — pinned by two controls.
5088+
let logged: ExecutionLogEntry | undefined;
5089+
try {
5090+
logged = this.recordLog({
50535091
id: runId,
50545092
flowName,
50555093
flowVersion: flow.version,
@@ -5061,6 +5099,45 @@ export class AutomationEngine implements IAutomationService {
50615099
steps,
50625100
error: errorMessage,
50635101
}, context);
5102+
} catch (bookkeeping) {
5103+
// #4632 verdict: DURABILITY, so `error` — the caller is told a
5104+
// truthful thing (the run failed, with the node's own text),
5105+
// which is exactly what makes the rest invisible from the
5106+
// outside: the terminal history row never landed, nothing
5107+
// retries it, and no envelope carries a word about it.
5108+
// Consequence and fix in the first line, per AGENTS.md. Said
5109+
// ONCE per abandoned write.
5110+
//
5111+
// ⚠️ NOT the rule's third answer ("a failure handed to the
5112+
// CALLER is not a degradation at all"): what is handed to the
5113+
// caller is the NODE's failure. The bookkeeping failure is
5114+
// handed to nobody, and it is a durability loss — which is why
5115+
// `recordLog` is in `DURABILITY_CRITICAL_CALLEES`.
5116+
//
5117+
// ⚠️ The level is the precedent's (#15555, #16273, #16274) and
5118+
// is NOT a #13398-class raise: that ruling forbids raising a
5119+
// site to `error` where doing so means GROWING `error?` onto a
5120+
// published sink that lacks it, and this sink — `Logger` from
5121+
// `@objectstack/spec/contracts` — declares `error(message,
5122+
// error?, meta?)` as a REQUIRED member. Nothing is widened and
5123+
// no sink type changes in this diff.
5124+
//
5125+
// THIRD argument per `error(message, error?, meta?)`; the
5126+
// `Error` slot stays empty on purpose (#5575), and the thrown
5127+
// text goes to the structured slot rather than into the message
5128+
// (#6499).
5129+
this.logger.error(
5130+
`[Automation] run '${runId}' of flow '${flowName}' FAILED and its run-history ` +
5131+
`bookkeeping threw, so its terminal 'failed' row never landed — nothing retries the ` +
5132+
`write, and after the next restart this run is invisible to the Runs surfaces while ` +
5133+
`the approvals sweeps read it as never-finished. The run's OWN failure IS reported: ` +
5134+
`the caller was answered status 'failed' carrying the node's error, so nothing ` +
5135+
`needs re-driving on account of this line. Fix the history failure in this record's ` +
5136+
`meta.`,
5137+
undefined,
5138+
describeThrownForLog(bookkeeping),
5139+
);
5140+
}
50645141

50655142
// [#10025] NEVER DISPATCHED, ruled NON-RETRYABLE (maintainer,
50665143
// 2026-08-20, Option B taken whole). The guard's verdict is a pure
@@ -5169,7 +5246,11 @@ export class AutomationEngine implements IAutomationService {
51695246
errorMessage: flow.errorMessage,
51705247
// A failed run's counts matter MORE, not less: they say how far
51715248
// it got before dying — how many rows it had already written.
5172-
summary: logged.summary,
5249+
// [#17562] Recomputed when the guard above had to abandon
5250+
// `recordLog`: the same pure function of the same steps that
5251+
// `recordLog`'s own first statement runs, so the two spellings
5252+
// cannot disagree. Same shape as #15555's.
5253+
summary: logged?.summary ?? summarizeRun(steps),
51735254
};
51745255
} finally {
51755256
// Release the re-entrancy guard for this (flow, record). Runs before
@@ -10395,7 +10476,27 @@ export class AutomationEngine implements IAutomationService {
1039510476

1039610477
const errorMessage = err instanceof Error ? err.message : String(err);
1039710478
const durationMs = Date.now() - startTime;
10398-
const logged = this.recordLog({
10479+
// [#17562] The SECOND initial-execution instance of the guard above
10480+
// in `execute()` — this path's own failure arm, one per RETRY
10481+
// attempt. The reachable statements, the invariant and the #13398
10482+
// reading are all stated at the `execute()` site; this is the same
10483+
// guard, not a second design.
10484+
//
10485+
// ⚠️ Fixing `execute()` alone would NOT have closed it, and the
10486+
// reachability is the opposite way round from #16274's: this method
10487+
// is only ever entered from {@link retryExecution}, which
10488+
// `execute()`'s catch reaches AFTER its own failed row. So a store
10489+
// that starts refusing mid-run (the first row lands, the driver's
10490+
// connection then drops) puts the throw in THIS arm and nowhere
10491+
// else, and the throw rejected out through `retryExecution` and
10492+
// `execute()` both — taking the remaining retry budget with it.
10493+
// The eighth instance of this method's documented drift from
10494+
// `execute()` (#9378, #9415, #9414, #9510, #9704, #9889, #16274
10495+
// before it), and the same chokepoint discipline applies: one
10496+
// shape, both attempt paths.
10497+
let logged: ExecutionLogEntry | undefined;
10498+
try {
10499+
logged = this.recordLog({
1039910500
id: runId,
1040010501
flowName,
1040110502
flowVersion: flow.version,
@@ -10407,6 +10508,25 @@ export class AutomationEngine implements IAutomationService {
1040710508
steps,
1040810509
error: errorMessage,
1040910510
}, context);
10511+
} catch (bookkeeping) {
10512+
// #4632 verdict: DURABILITY, so `error` — see the `execute()`
10513+
// site for why this is outside #13398's class and why a failure
10514+
// handed to the caller does not exempt it. The message names
10515+
// the ATTEMPT, because the run id an operator finds in the Runs
10516+
// surfaces is this attempt's own and not the first attempt's.
10517+
// Said ONCE per abandoned write.
10518+
this.logger.error(
10519+
`[Automation] run '${runId}' of flow '${flowName}' FAILED on a RETRY attempt and its ` +
10520+
`run-history bookkeeping threw, so that attempt's terminal 'failed' row never ` +
10521+
`landed — nothing retries the write, and after the next restart the attempt is ` +
10522+
`invisible to the Runs surfaces while the approvals sweeps read it as ` +
10523+
`never-finished. Neither the retry budget nor the caller's answer is affected: the ` +
10524+
`loop still runs its remaining attempts and still answers status 'failed' carrying ` +
10525+
`the node's error. Fix the history failure in this record's meta.`,
10526+
undefined,
10527+
describeThrownForLog(bookkeeping),
10528+
);
10529+
}
1041010530
// [#9378] The retry loop reads only `result.success` and this
1041110531
// result never escapes `retryExecution` on its own, but it is the
1041210532
// same ran-and-failed exit as the two above and is classified the
@@ -10423,7 +10543,11 @@ export class AutomationEngine implements IAutomationService {
1042310543
durationMs,
1042410544
status: 'failed',
1042510545
errorMessage: flow.errorMessage,
10426-
summary: logged.summary,
10546+
// [#17562] Recomputed when the guard above had to abandon
10547+
// `recordLog`: the same pure function of the same steps that
10548+
// `recordLog`'s own first statement runs, so the two spellings
10549+
// cannot disagree. Same shape as #15555's.
10550+
summary: logged?.summary ?? summarizeRun(steps),
1042710551
};
1042810552
}
1042910553
}

0 commit comments

Comments
 (0)