fix(service-automation): a failed run is still answered in the declared shape when its own history write throws - #17583
Conversation
…ed 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 <[email protected]>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6e403e355d4c52a404960903784e319ade98e5f4 && git checkout 6e403e355d4c52a404960903784e319ade98e5f4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ef180302f55955baf9994a91d0793bbd3926b068 190d2001f53e56ba7af44300a3a6bccedc2014f1 && git checkout -B drift-repro ef180302f55955baf9994a91d0793bbd3926b068 && git merge --no-ff 190d2001f53e56ba7af44300a3a6bccedc2014f1
node scripts/docs-audit/affected-docs.mjs --json ef180302f55955baf9994a91d0793bbd3926b068 |
Fixes #17562
Both initial-execution paths ended their node-failure
catchwith an unguardedrecordLog({ status: 'failed' }). Thatcatchis the handler for node failures and there is no outer one, so a throw out of the history write escaped the method entirely and leftexecute()a rejected promise, where its declared return type is anAutomationResult. This is the FAILURE-arm half of PR #17565 (which guarded the COMPLETION arms of these same two paths), and the same shape PR #15555 landed onresumeInternal's failure arm.The premise held, and it was re-measured rather than inherited
PR #17565's guard markers sit on the completion arms (
engine.ts:4868,:4965,:10246,:10300); its author annotated this card's site while working, at:4904, naming the exact case — the catch arm's ownrecordLog({ status: 'failed' })threw again. So the site was still open on the post-merge tree, and the card's own prediction is confirmed.The card's reproduction, re-driven on
origin/main@216b06682before a line was written — the identical flow (start -' work -' end,workthrows) and the identical node failure, differing only in the store:The control is what makes it a reading: the same node failure against a healthy store returns the declared envelope carrying the NODE's own text. Only the store differs.
What is lost is the SHAPE, not the verdict
The run really did fail, so nothing misleads an operator: no false
failed, no double run — which is why this is a narrower defect than #16274's and still a contract violation. A caller that branches on{ success: false, status: 'failed' }gets an exception instead, so the transport'sstatusarm (#9378) is bypassed,errorMessage(#9414) andsummary(#4354) never arrive, and the node's own error text — the one thing the caller needed — is replaced by the history driver's. A REST route or SDK caller sees a 500-class throw for a run that had a perfectly good failure envelope waiting.The second site is reached the OPPOSITE way round from #16274's
executeWithoutRetry()is only ever entered fromretryExecution(), whichexecute()'s catch reaches after its own failed row. So with a store that refuses every terminal write,execute()rejects first and the second site is never reached — which is why a store that starts refusing mid-run (the first row lands, the driver's connection then drops) is what puts the throw in that arm and nowhere else. The pin is written that way, so it measures the second site independently of the first rather than through it. Unpatched, that throw rejected out throughretryExecutionandexecute()both, and took the remaining retry budget with it: two node runs instead of three.What changed
recordLogis guarded at its own call site, the summary is recomputed with the same pure function (logged?.summary ?? summarizeRun(steps)), and the swallowed failure is reported once aterrorwith its consequence and its remedy. The shape PR service-automation: a throw betweenjournalConsumedSuspensionand thestatus: 'stranded'stamp makes a REPAIRABLE strand reportrepairable: false— the operator is told not to attempt a repair that works #15555 landed, twice over; no third design.catcharm's meaning is widened. The suspend arm, theInputSchemaViolationErrorrefusal and thestrategy: 'retry'branch are untouched; a genuine node failure still recordsfailed, still carries the node's own text, and is still retried the full1 + maxRetriestimes — pinned by two controls.scripts/**edit was owed, exactly as the dispatch predicted. Verified rather than assumed: see the gate section.The level question, verified on today's tree rather than inherited
The remedy reports at
error, and the standing #13398 ruling forbids raising a site toerrorwhere that means growing an optionalerror?onto a published sink that lacks it. Measured in one read ofpackages/spec/src/contracts/logger.ts(read-only to this lane, untouched by this diff):error(message: string, error?: Error, meta?: Record...): voidis declared at line 37 with no?before its parameter list — a REQUIRED member. Nothing is grown; no sink type changes in this diff.failed— or re-executed understrategy: retry— by its own history write #17565's body and the dispatch say "onlyfatal?,child?,withTrace?are optional". On today's tree the optional members are five, not three:fatal?(45),child?(51),withTrace?(58),log?(65),destroy?(71) — counted mechanically (5 optional vs 4 required:debug,info,warn,error). The load-bearing half is unchanged —erroris among the REQUIRED four — so the conclusion stands, but the enumeration it was stated with was incomplete. A fence inherited is an assertion about code as it was.pnpm check:optional-error-sinkexit 0.There is a second reason to state explicitly, because this site looks like the rule's third legal answer ("a failure handed to the CALLER is not a degradation at all"). It is not: what is handed to the caller is the node's failure. The bookkeeping failure is handed to nobody — the envelope, the HTTP arm and every counter read clean while
sys_automation_runhas no row — so the durability question answers YES anderroris right. That is also whyrecordLogsits inDURABILITY_CRITICAL_CALLEES.Verification
The reproduction, driven before a line was written (
origin/main@216b06682, both legs in one process):Ablation — RED before GREEN, proven by on-disk state. The fix was committed first, then ablated out of committed state (
git checkout 216b06682 -- packages/services/service-automation/src/engine.ts), with the mutation proven by hash AND by occurrence counts on the exact text touched, and restored by state:The script carried
trap restore EXIT INT TERMwith an absolutegit rev-parse --show-toplevelpath, and an empty hash was treated as FAILURE rather than as "nothing to compare".The direction OBSERVED (the predicted one, turning red): 5 pins failed, 2 controls passed — the controls passing on the ablated tree is correct, because they assert behaviour this change does not move. Quoted from the run:
exportsintodist/— measured, not assumed, and the answer is the opposite of PR #17565's.packages/services/service-automation/distdid exist at ablation time (the script printeddist present? : YES; the CI-shaped build below had already run), and the verdict flipped anyway. The pin imports./engine.jsrelatively, so vitest resolves it to the TypeScript source rather than through the package'sexports; the mutation changing the verdict with a populateddist/beside it is the proof that source is what ran. Restore leg on the restored tree: 7/7 green.Runs — every heavy run through⚠️ These are SHARED-BOX seconds, as the wrapper says; they exclude other locked runs only.
scripts/pm/os-verify-lock.sh, quoting its own verdict line.build('@objectstack/service-automation^...')VERDICT command-exit 0 · held the lock 251sVERDICT command-exit 1— 5 failed / 2 passedVERDICT command-exit 0— 7/7@objectstack/service-automationpnpm test+pnpm typecheckVERDICT command-exit 0— 132 files, 1564 tests, all passed;tsc --noEmitpluscheck:test-typecheck(test layer compiles, 0 files / 0 errors in the debt ledger), so the new pins are really type-checkedturbo run buildover./packages/*+./packages/*/*VERDICT command-exit 0— 72/72 tasks, run to satisfy two gate prerequisitesVERDICT command-exit 0— the block abovepnpm lint(whole repo,eslint . --no-inline-config)VERDICT command-exit 0at190d2001f— no narrowing claimed, the full scan ranturbois not on the lock shell's PATH, so the CI-shaped build first came backexit 127— a command that never started, not a red build. Re-run aspnpm exec turbo …to a real 0.Gates — derived from the ACTUAL diff with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, every derived command run, and reconciled with--rancarrying an exit code for each:Two were earned rather than claimed, and ⛔ neither was rounded up:
pnpm check:dual-build-cjs-loadsfirst returned exit 3 = PREREQUISITE NOT MET = NOT MEASURED ("this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured"). The prerequisite was satisfied with the CI-shaped build and the gate re-run to a real exit 0.pnpm check:type-check-debtlikewise: exit 3 -> prerequisite built -> exit 0 (5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number).Seven families were run beyond the 61, because the derivation marks their silence as a fact about a list rather than about these paths — the 4 artifact rosters that sit in a directory one of these paths is in (
check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity) and the 3 declared-wide families that actually bear on this diff (check:durability-log-level,check:optional-error-sink,check:swallow-census-controls). All exit 0.The
scripts/**question, answered mechanically.pnpm check:durability-log-leveljudged the two new seams with no gate-script edit at all, exactly as the dispatch predicted:35, against the 33 PR #17565 left behind — the two new guards, discovered through the
recordLogvocabulary that PR added and judged LOUD.check:swallow-census-controlsstayed green too (21 copied gate-vocabulary names still match the gate's declaration), so no by-value copy drifted.docs-drift — answered on the tree the bot names, and hand-searched blind to the emitter.
Zero rows, so there is nothing to answer row by row. The emitter-blind hand search over
content/docs, with itswc -land a firing control, because a page stating this rule by its inputs shares no identifier with the emitter:content/docsrecordTerminalSuspendedRunStorerun-historyautomation/flows.mdx— a UI panel;releases/v16.mdx— history, and off-limits)AutomationResulterrorMessagestatus: 'failed'api/client-sdk.mdx—ai.pendingActions, an unrelated surface)automationThe six
AutomationResultpages were read:references/automation/flow.mdx(twice, generated) documentssuccessMessage/errorMessageas "carried on AutomationResult for every terminal run" — a claim this change makes more true, not stale, since those two fields previously went missing exactly when the history write threw. No page documentsexecute()rejecting, and no page states the run-history best-effort rule. No docs edit is owed.NOT MEASURED, each with its reason — all CI-owned, none a skipped local obligation: the 5 path-scheduled CI jobs whose 27 steps have no local invocation (Build Core, the Test Core shards, the two Dogfood jobs), the families whose argv takes a value only a CI run supplies (
check-shard-attestation,check-test-completeness), and the 8 of the 11 declared-wide families whose populations are unrelated roots.Clause-②: no
Both limbs measured against the diff rather than recalled. ① Accept/reject behaviour — no Zod schema, parse path or accept set is touched, and
AutomationResult.statusalready declares'failed', so this change makes the declared envelope REACH the caller rather than minting anything. ② Public surface — no exported symbol, no new error code, no new key on any published payload; the change is atry/catcharound an existing call plus onelogger.error, andsummarizeRun/describeThrownForLog/ExecutionLogEntrywere all already in the file. Noscripts/**edit. This matches the seat's claim-time declaration.node scripts/pm/check-clause2-carriers.mjs --pair 17583— exit 0: the clause-② declaration is readable in the fixed spelling and both carriers agree, and its diff carries no widening tell. ⛔ Not judged with a hand-rolled regex: the gate's ownreadClause2Lineread it.⛔ Untouched, as the card and the dispatch both fence:
resumeInternal's already-guarded sites, the COMPLETION arms (#16274),restoreConsumedSuspension,inspectStrandedRequests(#15358), andpackages/spec/**(read-only).Authored by Claude Code in session
session_01ToDPcx9AESFubJkDiFMtKW— attribution in prose deliberately, because a PR body's trailing rule line plus footer block is eaten by the platform on write while the call still reports success.Generated by Claude Code
Generated by Claude Code