fix(service-automation): a completed run is no longer answered failed — or re-executed under strategy: retry — by its own history write - #17565
Conversation
…istory writes (#16274) `execute()` and `executeWithoutRetry()` called `recordLog({ status: 'completed' })` from inside the `try` whose `catch` exists for node failures, so a throw out of a history write on a run that had already finished was handled as though a node had thrown. Measured on the unpatched tree, and sharper than the report assumed: the false `failed` is handed to `retryExecution` by the strategy branch, whose loop reads `result.success` and re-executes the whole flow — `1 + maxRetries` runs of every node for one logical success, unattended, with one `failed` history row per attempt. Controls: the same flow on healthy sinks runs the node once; a genuine node failure runs it `1 + maxRetries` times. Guarded at each call site in the shape the resume path already landed: report the swallowed failure once at `error` with its consequence and fix, recompute the summary with the same pure function. No `catch` arm's meaning is widened. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <[email protected]>
…16274) AGENTS.md's rule for this gate: "it cannot discover a new seam, only stop known ones from regressing; found a new one, add it to DURABILITY_CRITICAL_CALLEES in the same PR that fixes it." `recordLog` is that seam. Measured: the population goes 29 -> 33 catch seams, the four newly covered are this card's two guards plus the two the resume path already landed, all four are judged LOUD, and the gate stays green with zero unrecognised constructs. The empty shrink-only baseline stays empty. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <[email protected]>
…16274) `measure-durability-swallow-family.mjs` keeps a deliberate by-value copy of the gate's `DURABILITY_CRITICAL_CALLEES` under `origin: 'gate-vocabulary'`, and its `--self-test=gated` reddens on drift — which it did, naming `recordLog` as "declared by the gate, missing from the copy". Its own remedy text: a name the gate gained belongs in the copy. check:swallow-census-controls: 21 copied gate-vocabulary names now match the gate's declaration, exit 0. 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 fc6c18d6a55eca8d4cf0f1e95a6e2c8b440bbfa6 && git checkout fc6c18d6a55eca8d4cf0f1e95a6e2c8b440bbfa6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 245f3607d3c8bb0a5f828d782a758c53c86aa5a8 18b177466154e1a4ac78cf5ebaa435e56d35f973 && git checkout -B drift-repro 245f3607d3c8bb0a5f828d782a758c53c86aa5a8 && git merge --no-ff 18b177466154e1a4ac78cf5ebaa435e56d35f973
node scripts/docs-audit/affected-docs.mjs --json 245f3607d3c8bb0a5f828d782a758c53c86aa5a8 |
Answering the Docs Drift Check — on the tree it named, plus the read it says it can never doRe-derived on the bot's own tree, not on my worktree's: fetched the merge commit It also names what the bot's prose summarised: the single anchor is Control for every zero. The instrument fires: The emitter-blind read, which is where a real answer had to come fromThe bot's own warning: "a page that states a rule by its inputs shares no identifier with the emitter that implements the rule… a rule your change carries has to be re-read by hand in the pages that restate it." This diff carries exactly that kind of rule, so here are the three pages that restate it, each named with its collision rather than waved past. 1.
⇒ Not falsified, and the nearest miss in the set. The row's subject is a flow that FAILED. What this PR removes is a re-run of a flow that did not fail — every node succeeded and only its own history write threw — which this row never described. The row is if anything more true afterwards: before this PR a completed flow re-ran from the start up to 2.
⇒ Not falsified; this PR moves the degraded case INTO agreement with it. The page shares no identifier with the emitter (it names 3.
⇒ Not falsified. This PR neither moves that line's level nor its emission nor its default; it only catches the throw a host-injected The coarse fallback, read tooThe 6 What I am NOT claiming⛔ Not a clean bill of health, for the reason the bot gives: the SDK route bridge reached 60 of 215 client-bound ledger rows, and pages documenting the other 155 client methods are invisible on every run. I read the pages the rule lives in, not every page that could exist. The 55 structural rows in that gap are beyond any discovery change, per the bot's own accounting. Generated by Claude Code |
Fixes #16274
A completion-path
recordLog({ status: 'completed' })was called from inside thetrywhosecatchexists for node failures, on both initial-execution paths. So a throw out of a history write, on a run that had already finished, was handled as though a node had thrown. This is the guard PR #16273 landed onresumeInternal's completion path, on the two sites that card deliberately scoped out.⭐ The measurement came first, and it moved the card's grade
Triage attached a re-grade trigger and asked for it to be driven before any code was written — 「若它能重试一个已经完成的 run,本卡立刻从 p3 变成 p2 甚至更高」. It was driven on
origin/main(9540590ba) with the doubles PR #16273 already shipped, and it can. Posted on the card first: #16274 (comment)execute()recordTerminalthrows SYNCHRONOUSLYexecute()logger.infothrows, NO storesuccess: false, status: 'failed', the log transport's text as the run's errorlogger.infothrows, NO storefailedenveloperecordTerminalthrows SYNCHRONOUSLYsuccess: truesuccess: false, status: 'failed', the node's textexecute()success: trueM3 against M5 and M6 is the finding. Same flow, same
maxRetries: 2, same counter: healthy sinks run the node once; a genuine node failure runs it three times (retry working correctly); and a run whose every node SUCCEEDED also runs three times — because the falsefailedis handed toretryExecutionby the strategy branch, and that loop readsresult.success. Threefailedrows for one logical success, the node's side effects three times, unattended, inside oneexecute()call. It needs no store at all: the run-summary line is on by default (this.runSummaryLog = options?.runSummaryLog ?? 'info') and calls a host-injectedLogger.⇒ That is the double run #15944 was graded sharp for, and on one axis sharper: #15944's re-run needed an operator to restore and resume. ⛔ The
priority:p3->priority:p2flip is the triage/PM seat's to apply, not this seat's.The two questions the card left unmeasured, both answered. The throw reaches the generic failure arm at both sites: the thrown value is a plain
Error, soisSuspendSignal(err)is false anderr instanceof InputSchemaViolationErroris false — and the envelope proves both negatives, carryingstatus: 'failed'with NOcodewhere the input-schema arm returnscode: 'FLOW_INPUT_SCHEMA_INVALID'and the suspend arm returnsstatus: 'paused'. And nothing downstream distinguishes it from a genuine node failure: compare M3 with M6 field by field — identical shape, identical discriminator, identical HTTP arm. The only difference is the content oferror, which is a message regex away from being readable and therefore not a distinction (PD #12).Re-located by structure (the card's line numbers were from
48427620aand it said to), onorigin/mainat9540590ba:status: 'completed'catch:4873:4913execute()(:4681):6089:6154resumeInternal()(:5609):10161:10179executeWithoutRetry()(:10064)The card's "the retry path" site is⚠️ Fixing
executeWithoutRetry(), notretryExecution()(:9851): the latter holds the loop and the backoff and calls the former once per attempt, which is where that attempt's completion write and its node-failurecatchlive.execute()alone would not have closed it — a flow understrategy: 'retry'whose attempt 2 completes leaves throughexecuteWithoutRetry()'s exit and never crossesexecute()'s again.What changed
fix(service-automation)— each completion-pathrecordLogis 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 fix. The precedent's shape, twice; ⛔ no third design. ⛔ Nocatcharm's meaning is widened: a genuine node failure still lands in the node-failure arm, still recordsfailed, still carries the node's own text, and is still retried the full1 + maxRetriestimes — pinned by two controls.chore(gates)×2 — AGENTS.md's rule forcheck:durability-log-levelis "it cannot discover a new seam, only stop known ones from regressing; found a new one, add it toDURABILITY_CRITICAL_CALLEESin the same PR that fixes it."recordLogis that seam. Measured before landing it: the population goes 29 -> 33 catch seams (this card's two guards plus the two the resume path already landed), all four judged LOUD, gate green, zero unrecognised constructs, the shrink-only baseline still empty. The paired by-value copy inmeasure-durability-swallow-family.mjsthen reddened on drift exactly as designed (declared by the gate, missing from the copy: recordLog) and was carried over, its own remedy text followed.The card's suggested remedy says report at
error, and both precedents (#16273, #15555) landed that level — but this lane carries the standing ruling that a site reporting through a published sink shape must not be raised toerror. Established, not assumed:warnwhere AGENTS.md puts it aterror— and the card that was supposed to carry the level is CLOSED #13398-class sites state it, is that what is forbidden is raising a site toerrorwhere doing so means GROWINGerror?onto a published sink that lacks it (its option B). A sink that already declareserrorwidens nothing —share-link-service.tssays so in those words and takeserroron that basis;Loggerfrom@objectstack/spec/contracts, whereerror(message, error?, meta?)is a REQUIRED member. Onlyfatal?,child?andwithTrace?are optional. Nothing is grown, nowarnfallback branch is owed, and no sink type changes in this diff;pnpm check:optional-error-sink(exit 0) censuses 50 sink types declaringerrorunderpackages/**, of which 14 declare it REQUIRED — "nothing to guarantee".Loggeris in that 14, which is also why the two precedents on this same sink could landerror.⇒ This site is OUTSIDE #13398's class, so the precedent's shape AND its level are both copied. Reported here rather than resolved silently, as the dispatch required; it is a reading, not a judgement call.
Clause-②: no
Both limbs measured against the diff, not recalled. ① Accept/reject behaviour — no Zod schema, parse path or accept set is touched;
AutomationResult.statusalready declares'completed' | 'paused' | 'failed'and this change emits no new value, it emits the ABSENTstatus+success: truethat the healthy completion path already returns. ② 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, andsummarizeRunwas already imported in the file. The two gate-script edits are repo tooling, not published (files[]is["dist", "README.md", "CHANGELOG.md"]). This matches the seat's claim-time declaration.Verification
RED before GREEN, proven by on-disk state. The fix was committed first, then ablated out of committed state (
git checkout 9540590ba -- 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 afterwards:The ablation 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 (not the one predicted): 5 pins failed, 3 controls passed — the controls passing on the ablated tree is correct, since they assert behaviour this change does not move. The two readings that matter, quoted from the run:
dist/could have served stale bytes on that leg:packages/services/service-automation/distdid not exist at ablation time (checked and printed by the script —dist present? : NO; only the dependency closure had been built), and the test resolves./engine.jsrelatively to the TypeScript source rather than through the package'sexports. The mutation changing the verdict is itself the proof that source is what ran.Runs — every heavy run through
scripts/pm/os-verify-lock.sh, quoting its own verdict line:build('@objectstack/service-automation^...')VERDICT command-exit 0 · held the lock 259s@objectstack/service-automationpnpm testVERDICT command-exit 0— 131 files, 1554 tests, all passed@objectstack/service-automationpnpm typecheckVERDICT command-exit 0—tsc --noEmitpluscheck:test-typecheck(test layer compiles, 0 files / 0 errors in the debt ledger), so the new pins are really type-checkedVERDICT command-exit 1— 5 failed / 3 passed (the RED above)VERDICT command-exit 0— 8/8turbo run build --filter='./packages/*' --filter='./packages/*/*'VERDICT command-exit 0— 72/72 tasks, run to satisfy two gate prerequisitespnpm lint(whole repo,eslint . --no-inline-config)VERDICT command-exit 0at18b17746— no narrowing claimed, the full scan ranGates — derived from the actual diff with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after thescripts/**edits (61 commands -> 82), and reconciled. All exit 0, with two earned the hard way: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/"). ⛔ Not rounded up: the prerequisite was satisfied with the CI-shaped build above 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).pnpm check:swallow-census-controlsreturned a genuine exit 1 on the drift described above, and is exit 0 after the paired copy was carried over.check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity, all exit 0. So were the gate script's own suites (--self-test: 63 + 57 cases) and the siblings that read it (check:ratchet-remedy-authority,check:cross-package-test-inputs,check-scripts-symbol-anchors,check-declaration-mirrors,check-self-test-wired).NOT MEASURED, each with its reason — all of them CI-owned, none of them a skipped local obligation: the 5 families whose argv takes a value only a CI run supplies (
check-shard-attestation,check-test-completeness); the 5 CI jobs scheduled by these paths whose 27 steps have no local invocation (Test Core shards, Temporal Conformance on live PG/MySQL, the two Dogfood jobs, Build Core); and the 11 families the derivation marks as declaring a population too wide to place — of which the two that actually bear on this diff were run anyway (check:optional-error-sink,check:durability-log-level), the rest being unrelated roots.Out-of-scope finding — reported, ⛔ not fixed here
Filed as #17562, bare, with a reproduction and a firing control. The failure-arm
recordLog({ status: 'failed' })on these same two paths is unguarded, so on the tree this PR produces, a genuine node failure plus a synchronously throwing store makesexecute()reject instead of answering the declared envelope:A different site with a different consequence (the run really did fail, so nothing misleads an operator — what is lost is the SHAPE), which is why it is a separate card and is deliberately not pinned here: freezing a known defect in this PR's test file would be worse than naming it. #17562 is not addressed by this PR.
⛔ Untouched, as the card and the dispatch both fence:
resumeInternal's already-guarded site,restoreConsumedSuspension,inspectStrandedRequests(#15358),suspended-run-store.tsandsys-automation-run.object.ts(held by the #15336 dev in flight), andpackages/spec/**.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