fix(service-automation): re-seat the suspension map entry when the durable save fails - #16216
fix(service-automation): re-seat the suspension map entry when the durable save fails#16216os-warren wants to merge 3 commits into
Conversation
…rable save fails A concurrent per-id `loadSuspendedRunStrict` landing inside `persistSuspendedRun`'s save window evicts the live map entry (#16129's base window, which stays as pinned). Compounded with the save then FAILING, the run was left with neither a durable row nor a map entry: `hasSuspendedRun` answered `false` and `resume` answered `RUN_NOT_FOUND` — the run lost in-process, not merely un-durable — while the engine's own `error` record told the operator it was "kept in memory only" and that they had until the next restart to act. The catch now re-seats the map entry alongside the cache-only marking, so the marking qualifies something again. Option C of the card: the cache-only marking is not widened (that would weaken #13617's store authority), no lock is added, the save is not reordered, and the base window is untouched. The operator record is corrected in the same seam: it keeps its promise, and now names the two reads (`hasSuspendedRun()`, `listSuspendedRuns()`) that must answer for the run, so the promise can be falsified instead of trusted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…record Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…im on evictConsumedSuspension The compound case that paragraph pointed at is closed by the re-seat in `persistSuspendedRun`'s catch, and the pin file now records the intended outcome rather than the measured loss. Comment-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 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 7745e79e5e523f42a62cb934de1a43fa83c68ff8 && git checkout 7745e79e5e523f42a62cb934de1a43fa83c68ff8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4c0b22bf758a7a51e46bb756310d3ada29197f0c 18a3dbfb77930e12189060a446d3086670f5fd9c && git checkout -B drift-repro 4c0b22bf758a7a51e46bb756310d3ada29197f0c && git merge --no-ff 18a3dbfb77930e12189060a446d3086670f5fd9c
node scripts/docs-audit/affected-docs.mjs --json 4c0b22bf758a7a51e46bb756310d3ada29197f0c
|
PM 验收 · 两条承重论证我逐行核过,都成立;⭐ 文案那一处你解得比我提的更好① 「
|
Contract review (clause ②) — PASS on content · landing held on the pair declaration — PR #16216 at head
|
Fixes #16151
A suspended run whose durable save fails was being lost in-process — not merely left un-durable — when a concurrent per-id read had already landed inside the save window. The engine's own
errorrecord for that failed save told the operator the opposite.Where the window is on current head
Base is
bdc02182b, which already carries PR #16150's pin.AutomationEngine.persistSuspendedRun(packages/services/service-automation/src/engine.ts), line numbers re-located on that base, not inherited from the card:bdc02182b:2053this.suspendedRuns.set(run.runId, run)listSuspendedRuns, so a consumer needs no out-of-band knowledge of it:2056await this.store.save(run):2067this.cacheOnlySuspensions.add(run.runId):2080thelogger.errorrecordThe card cites
:2003for step 4; the triage seat had already corrected that to:2064-2065. On this base it is:2080. The other three line numbers hold.Between 1 and the resolution of 2 the entry is live and not yet qualified, so a concurrent
loadSuspendedRunStrict(:5096on base) reads a store that truthfully has no row, finds no qualifier, and takes #16031's eviction path (evictConsumedSuspension,:2252on base, whose two guards are "no store" and "cache-only"). Compounded with the save then failing, the catch marked the run cache-only while the map entry that marking qualifies had already been evicted: the qualifier qualified nothing,hasSuspendedRunansweredfalse, andresumeansweredRUN_NOT_FOUND.Preconditions — not theoretical
Reaching this needs a store that rejects the write while still answering reads with "no row", rather than throwing: a healthy read replica behind a broken write path, a missing
INSERTgrant, a full disk. A store whose read throws cannot reach it at all — that is one of the controls, and it still passes.The option taken, and why the others were not
Option C. The failure path now re-seats the map entry alongside the cache-only marking (head
18a3dbfb7,engine.ts:2102), so the marking qualifies something again.Two arguments make the re-seat safe rather than merely narrow, and both are stated at the site:
forgetSuspendedRun, reachable only onceloadSuspendedRunStrictanswers for the run — and for the whole of this await it answersnull, for exactly the reason that opened the window.persistSuspendedRunis the only writer ofsuspendedRuns(git grepon head: one.set, two.delete), and a second park of the same run needs a resume that the samenullrefuses.Option B was not taken and is not proposed here. Marking the run cache-only before awaiting the save would close the base window too, with no lock — but it makes a run briefly readable out of the map while the store is still authoritative for it. That is a deliberate weakening of the #13617 store-authority invariant, whose signature in this very function is the
[#13617]comment on the success path, and it is the "widen the cache-only marking" move #16129 reserved to its own review. Nothing measured here argues it is necessary: C turns the escaping case green on its own. If anyone wants B, it belongs on its own decision card.Option D (accept as measured) was rejected on the measurement. D rests on "a store failing writes while answering reads is already an alarm-raising state", which is true and is not the same claim as "the run may be lost". The run that is lost is a pause — a paused approval no decision can ever advance — and the loss is silent on every surface a consumer has. The cost of C is one line on a path that only runs when the store has already refused the row, so the trade D asks us to accept is not being paid for anything.
The corrected operator message
The old sentence understated the harm in the direction that costs the most: it told the operator the run was still in memory and that the deadline was the next restart, so an operator would look for the run only after a restart and would blame the restart.
Base:
Head:
The remedy is not to weaken the promise. Under C the state in which it did not hold no longer exists, so weakening it would delete a guarantee the code now keeps. What the message gains instead is falsifiability: it names the two reads that must answer, so an operator can check the claim rather than trust it, and a future regression in this seam is visible to the person holding the log line instead of only to a test. That is asserted, not just written — the second new test drives the interleaving, reads the message the engine actually logged, and asserts each named read against the state it describes, so the prose and the behaviour cannot drift apart without turning red.
Both substrings the #4632 / #6499 pins depend on (
NOT be resumable after a restart,this record's meta) are preserved, the record stays one physical line, the level stayserror, and the driver's own text stays in the structured slot.engine-residual-log-cause.test.tspasses unchanged.Which of #16150's pins moved, and which did not
packages/services/service-automation/src/suspended-run-mid-park-eviction-window.test.ts.Updated from measured-behaviour to intended-behaviour — one test: the
FINDINGcase,an evicting read inside the window of a save that then fails leaves the run unresumable. PR #16150 pinned it at the measured loss and said in its own header that it was pinned as measured, explicitly not as desired, so a fix would have a red test to turn green. It now asserts the intended outcome: the store still never took the row, and the run is resumable in-process —hasSuspendedRuntrue,listSuspendedRunsreports it,getSuspendedScreenanswers, and it advances to the next approval level and completes end to end.Added — one test: the message assertion described above.
Unchanged, and still passing — the four that bound the base window: both
THE WINDOWtests (first park and re-suspend), and bothCONTROLs (no store attached; a read that throws). The isolating control inside the flipped test is kept verbatim: the identical failing save without the mid-park read leaves the run resumable, exactly as the documented degradation says. That control was the half that already behaved correctly — the two now agree, which is the entire content of the fix.The file header is rewritten to match: the section that read "One measured case that does NOT stay inside those bounds" now records that the case is closed, by which option, and what was deliberately not done.
Also in this diff
One comment-only correction in the same seam:
evictConsumedSuspension's docblock still said the pin file records "the one compound case that escapes those bounds". That sentence became false with this change, and a stale rationale comment in this file is how the boundary would become folklore again. It now points at the re-seat.Verification
All commands run in a dedicated worktree off
bdc02182b, every exit code captured immediately after a single redirected command, never through a pipe. Final head18a3dbfb7; every reading below was taken on that head.Gate family, derived mechanically —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived on the final head, no paths passed by hand. Reconciliation line: 56 families (44 by path + 7 by change kind + 7 declared whole-tree, 2 reached both ways). All 56 harvested with--commandsand run: 56 exit 0. The 6 families whose argv takes a value from the workflow are outside that total and were not run — there is no local invocation of them.pnpm --filter @objectstack/service-automation exec vitest run— 118 files / 1414 tests passed.pnpm --filter @objectstack/service-automation typecheck— clean, includingcheck:test-typecheck(test layer, 0 files / 0 errors of debt). Proven to be a measurement about the edited files rather than a vacuous pass:tsc -p tsconfig.test.json --listFilesnames bothsrc/engine.tsandsrc/suspended-run-mid-park-eviction-window.test.tsin the program.pnpm lint— the repo-wideeslint . --no-inline-configscan, not a narrowed run: exit 0.pnpm check:type-check-debtfirst returned exit 3, PREREQUISITE NOT MET — its internal tsc OOM'd under aNODE_OPTIONSceiling tighter than the 6144 MB the gate pins. That is NOT MEASURED, and is reported as such rather than as a pass or a finding. Re-run with headroom:12 ledger entries re-measured, 140 raw tsc errors total, none above its recorded number.Ablation — the re-seat is load-bearing. Committed first, then mutated: the single
this.suspendedRuns.set(run.runId, run)inside the catch was deleted, leaving its comment in place so only the call moved.git hash-object1163b2937…to89bc02eae…, and the anchored count of that call in the file went 2 to 1.expected false to be true), and the 4 base-window and control tests still pass — which also measures that the fix is confined to the compound case and carries none of service-automation: the mid-park window inpersistSuspendedRunis an unpinned limit — a concurrent per-id read can evict a live entry between the map write and the store save #16129's pinned bounds.trap … EXIT INT TERMwith an absolute path pinned togit rev-parse --show-toplevel, and the restore proven, not assumed: the blob hash is byte-equal to theHEADblob1163b2937…andgit diff HEADis empty. Re-run after restore: green../engine.js) from inside its own package, so vitest resolves it from source and nodist/sits between the mutation and the measurement.Not asserting anything about CI state. The 56 families above are the cheap local half; the authoritative reading is the CI jobs on this PR.
Scope
#15944 (a run whose nodes all succeeded, journalled and reported
strandedwhen its terminal history write throws) is not addressed here and was not absorbed: it lives on the history-write path, which this diff does not touch. #16129 remains open as the base window it pins — this change deliberately leaves that window exactly as pinned.Changeset:
patchon@objectstack/service-automation. Graded patch because the surface is unchanged — no signature, option or type moves — and what changes is the behaviour ofresume/hasSuspendedRun/listSuspendedRunsin a failure interleaving, plus the text of one log record. Nothing is added for a consumer to adopt, and nothing they call changes shape.Generated by Claude Code