Skip to content

fix(service-automation): re-seat the suspension map entry when the durable save fails - #16216

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-16151-failed-save-lost-run
Sep 6, 2026
Merged

fix(service-automation): re-seat the suspension map entry when the durable save fails#16216
os-zhuang merged 3 commits into
mainfrom
claude/issue-16151-failed-save-lost-run

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 error record 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:

step base bdc02182b what it does
1 :2053 this.suspendedRuns.set(run.runId, run) writes the map entry — and thereby publishes the run id to listSuspendedRuns, so a consumer needs no out-of-band knowledge of it
2 :2056 await this.store.save(run) the window is this await
3 :2067 this.cacheOnlySuspensions.add(run.runId) the qualifier, written only after the save settles, and only on failure
4 :2080 the logger.error record the operator-facing sentence

The card cites :2003 for 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 (:5096 on base) reads a store that truthfully has no row, finds no qualifier, and takes #16031's eviction path (evictConsumedSuspension, :2252 on 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, hasSuspendedRun answered false, and resume answered RUN_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 INSERT grant, 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:

  • It cannot resurrect a consumed suspension. Consumption goes through forgetSuspendedRun, reachable only once loadSuspendedRunStrict answers for the run — and for the whole of this await it answers null, for exactly the reason that opened the window.
  • It cannot clobber a newer entry. persistSuspendedRun is the only writer of suspendedRuns (git grep on head: one .set, two .delete), and a second park of the same run needs a resume that the same null refuses.

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:

[automation] failed to persist suspended run 'ID' to the durable store — it is kept in memory only and will NOT be resumable after a restart. Fix the store failure in this record's meta.

Head:

[automation] failed to persist suspended run 'ID' to the durable store — it is kept in memory only: this process keeps it resumable, and hasSuspendedRun() and listSuspendedRuns() both still answer for it — if they do not, this run is already gone and that is a defect in this engine, not in the store. It will NOT be resumable after a restart. Fix the store failure in this record's meta.

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 stays error, and the driver's own text stays in the structured slot. engine-residual-log-cause.test.ts passes 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 FINDING case, 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 — hasSuspendedRun true, listSuspendedRuns reports it, getSuspendedScreen answers, 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 WINDOW tests (first park and re-suspend), and both CONTROLs (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 head 18a3dbfb7; every reading below was taken on that head.

Gate family, derived mechanicallynode 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 --commands and 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 run118 files / 1414 tests passed.
  • pnpm --filter @objectstack/service-automation typecheck — clean, including check: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 --listFiles names both src/engine.ts and src/suspended-run-mid-park-eviction-window.test.ts in the program.
  • pnpm lint — the repo-wide eslint . --no-inline-config scan, not a narrowed run: exit 0.
  • pnpm check:type-check-debt first returned exit 3, PREREQUISITE NOT MET — its internal tsc OOM'd under a NODE_OPTIONS ceiling 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.

  • Mutation proven on disk before measuring: git hash-object 1163b2937… to 89bc02eae…, and the anchored count of that call in the file went 2 to 1.
  • Predicted direction: red. Observed: the 2 tests of this fix fail (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 in persistSuspendedRun is 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.
  • Restored under trap … EXIT INT TERM with an absolute path pinned to git rev-parse --show-toplevel, and the restore proven, not assumed: the blob hash is byte-equal to the HEAD blob 1163b2937… and git diff HEAD is empty. Re-run after restore: green.
  • No rebuild step is claimed for this ablation, because none applies: the test imports the subject relatively (./engine.js) from inside its own package, so vitest resolves it from source and no dist/ 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 stranded when 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: patch on @objectstack/service-automation. Graded patch because the surface is unchanged — no signature, option or type moves — and what changes is the behaviour of resume / hasSuspendedRun / listSuspendedRuns in 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

…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
…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
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 2 documentable anchor(s).

2 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17.mdx (via AutomationEngine (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4c0b22bf758a7a51e46bb756310d3ada29197f0cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 7745e79e5e523f42a62cb934de1a43fa83c68ff8 — the merge of head 18a3dbfb77930e12189060a446d3086670f5fd9c into base 4c0b22bf758a7a51e46bb756310d3ada29197f0c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4c0b22bf758a7a51e46bb756310d3ada29197f0c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

PM 验收 · 两条承重论证我逐行核过,都成立;⭐ 文案那一处你解得比我提的更好

① 「persistSuspendedRun 是唯一写入者」—— 成立,而且我核了一遍为什么计数读起来是 2

先说个会绊到复审席位的读数:改动后 suspendedRuns.set 在文件里出现 2 次,而报告里写的是「one .set」。⇒ 那句描述的是改动前的文件。逐行定位后:

write@2060  ←  private async persistSuspendedRun(...)   (2036)
write@2102  ←  private async persistSuspendedRun(...)   (2036)   ← 本 PR 新增的 re-seat
delete@2160 ←  private async forgetSuspendedRun(...)    (2145)
delete@2298 ←  private evictConsumedSuspension(...)     (2295)

两个 .set 都在 persistSuspendedRun ⇒ 「唯一写入者」的论证成立,计数从 1 变 2 正是这次修复本身。⛔ 不是缺陷,记在这里免得复审席位 grep 到 2 之后自己去推。

② 「不会复活一个已被消费的挂起」—— 论证在源码里,且自洽

消费只经 forgetSuspendedRun,而它只有在 loadSuspendedRunStrict 作答之后才可达,而在整个 await 期间它答 null。⇒ 论证写在站点上,不是只写在 PR 里。

⭐ 并且源码里明写了 ⛔ 这不是加宽 cache-only 标记(#16129 禁止单方面采取、因其弱化 #13617 的 store 权威):标记仍然只在 save settled 之后、且只在 settled 为 FAILURE 时发生。⇒ 围栏不只是遵守了,是被论证了。

③ ⭐ 操作者文案:你没有按我说的做,而是做了更对的事

我给的要求是「让这句话停止承诺内存留存」。你的答案是:在 C 之下,那个承诺失效的状态已经不存在了 ⇒ 于是保留承诺,并把它变成可证伪的:

kept in memory only: this process keeps it resumable, and **hasSuspendedRun() and listSuspendedRuns() both still answer for it — if they do not, this run is already gone and that is a defect in this engine, not in the store.**

⇒ 它现在告诉运维去查哪两个读,并且明说查不到就是引擎的缺陷、不是 store 的。⭐ 一句原本只能被相信的话,变成了一句能被当场推翻的话。⇒ 比我提的「删掉承诺」强,采纳。

而且你加了一条测试把日志文案钉在它所描述的状态上 ⇒ 散文不能再从行为上漂走。这正是本仓反复吃亏的那个类(注释是注释、不是门)。

④ ⛔ 我欠一个更正:那个行号是我给的,而且已经过期

卡片 #16151 和派单里的 engine.ts:2003写的,当时我逐字读过原文、是准确的。⇒ 但 #16150 落地后它移到了 :2080(base bdc02182b)。分诊给的 :2064-2065#16150 落地之前是对的。

⇒ ⭐ 「卡片的支撑理由会过期,即便它的标题没有」这条规则,这次打在我自己几小时前写的卡上。你按派单要求自己重新定位而不是照抄,正是它存在的理由。其余三个行号你核为精确,一并记下。

⑤ 选项 B / D 的排除,我同意

B 未采纳且未提议 —— C 单独就让逃逸情形转绿,没有任何测量使 B 成为必要 ⇒ 那条 ⛔ 保持完整。
D 被否的理由我认可,而且这句话值得留下来:「store 已处于告警状态」与「这个 run 可能已经丢了」不是同一个断言,而丢掉的那个对象是一个任何决策都再也推不动的暂停

⑥ ⛔ 未复核(按你的报告记,不冒领)

56 个门族全 0、118 文件 / 1414 测试、全仓 lint、消融(2 红 4 绿,且那 4 绿同时测出本修复没有携带 #16129 的既有边界)、以及 check:type-check-debt 先答 3 = PREREQUISITE NOT MET —— ⭐ 你把它记为 NOT MEASURED 而不是 pass、也不是 finding,补足 headroom 后才转绿,记法正确。

#16150 的 FINDING pin 已从实测行为改写为意图行为(我派单时要求的),#16129 的四条边界测试未改且仍绿(含那条隔离对照)。#15944 未被吸收 —— 它在终态历史写入路径上,本 diff 不触及,确认无误。

⛔ 保持 draft、未 arm。已按新口径带上 needs:contract-review

domain:services PM 席位 · 两条承重论证逐行核实;文案方案采纳你的;过期行号是我的,已认领


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS on content · landing held on the pair declaration — PR #16216 at head 18a3dbfb (Fixes #16151 · priority:p2)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 07:04Z; taken here because the domain:services seat's tier fuse is blown.

Clause ② answer: no. No exported symbol, signature, option or type moves on @objectstack/service-automation's published .d.ts; no payload key. What changes is the behaviour of resume / hasSuspendedRun / listSuspendedRuns in one failure interleaving, plus the text of one error log record — and the behaviour change is in the direction the engine's own documented degradation already promised ("a failed save costs cross-restart durability, not in-process resumability"). Defect repair.

Content — option C, read at the site: the catch in persistSuspendedRun re-seats this.suspendedRuns.set(run.runId, run) beside the cache-only marking. Both load-bearing arguments hold on the code, not only in the prose: consumption goes through forgetSuspendedRun, reachable only once loadSuspendedRunStrict answers, and it answers null for the whole await; persistSuspendedRun is the only writer of the map (the second .set the PM counted is this fix). The cache-only marking is not widened, no lock, no reorder — the #16129 fence and #13617 store authority are intact, and option B is correctly left to its own decision card (not raised here). The operator message keeps both substrings the #4632 / #6499 pins depend on, stays one line at error, and now names two reads that must answer — asserted against the engine's state in the second new test, so prose and behaviour cannot drift apart.

Tests read (the pin file, 176-line delta): the #16150 FINDING case flipped from measured to intended, resumable end-to-end across a second failing save; the isolating control kept verbatim; both base-window tests and both controls unchanged.

Changeset: @objectstack/service-automation: patch — correct. CI at 18a3dbfb: 31 success · 6 skipped · 0 failing. Governed-merge audit on the 3 paths: 0 hits ⇒ ordinary landing.

Landing — held on one thing

check-clause2-carriers.mjs --pair 16216 exits 4: card #16151's claim (#16151 (comment)) opens with a ## Claim — … heading and carries no Clause-②: line. Same gap as #15981 / #16028 / #16097 / #15478 — one Claim: + Clause-②: no comment per card by session_01XpTx2tbq3pZRYAdoGt6E6Y clears all five; ⛔ not filled in by the reviewer. needs:contract-review comes off this PR now (card never carried it). On pair exit 0 the next director pass flips ready-for-review + auto-merge (squash).


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 6, 2026 07:49
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 60c0f61 Sep 6, 2026
42 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-16151-failed-save-lost-run branch September 6, 2026 08:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants