fix(store): never write the ledger from a snapshot older than the file - #38
Merged
Merged
Conversation
A TaskStore caches its snapshot forever (load() is a no-op once loaded) and mutate() persists the WHOLE document. When two instances share one ledger file — a plugin hot reload leaves the previous generation's long-lived callbacks (whenIdle settlement watchers, the 4s session scan) running — the stale instance's next write silently rolled the newer instance's commits back: same revision, no error, nothing for subscribers or the GUI to observe. Re-read the file whenever it has moved ahead of the cached copy, so a cached snapshot is never authority over a newer document. Guarded on revision monotonicity, so a store that is already current is a no-op and no extra I/O happens on the common path. tests/store-staleness.spec.ts pins all three behaviours; two of them fail without this change (verified by reverting the guard).
The first revision of the spec cast a partial object, which tsc rejects (TS2352). Build the fixture the same way the existing specs do.
Contributor
Author
|
For the two neighbouring defects I found while diagnosing this, I opened separate issues rather than widening this PR:
Landing this one first is useful for #39: adoption (keeping surviving runs alive across generations) widens the window in which two store instances coexist, which is exactly the failure this PR removes. |
1254087415
pushed a commit
to 1254087415/dsh-taskboard
that referenced
this pull request
Sep 26, 2026
上游 8 个 commit(v0.8.1 → v0.8.3): - cloader#33 执行开场注入改用看板自身的 source kind(适配 DSH 0.1.7-rc.2 的 v4 会话格式) - cloader#35 定期任务完成策略(默认「完成后新建待办」) - cloader#37 会话跳转改走 uiWorkspace.openSession(sessions.open 在 DSH 0.1.6+ 已移除) - cloader#38 台账写入拒绝用旧快照覆盖新文件;cloader#39/cloader#40 结算与认领超时判定修正 - devDeps 升到 0.1.7-rc.2 冲突解决(仅 2 处源码,其余 lib/* 由 npm run build 覆盖): - src/client/index.ts:保留本 fork 的 mountSessionCardLinks 接线,同时引入上游的 UiWorkspaceFace / getUiWorkspace - src/shared/version.ts:采用上游 0.8.3 本地 5 项增强逐项核对保留:会话自动跟踪、会话导入、会话↔卡双向跳转、 侧边栏 4 数字统计、Agent 协议纪律 9/10。 测试:360/360 通过(本机 Node 26 需 NODE_OPTIONS=--localstorage-file 才不会被 Node 自带的 localStorage 全局遮住 jsdom 实现)。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
Two
TaskStoreinstances can point at the same ledger file. That happens in practice whenever the plugin is hot-reloaded: the previous generation's long-lived callbacks (thewhenIdle()settlement watchers and the 4-second session scan) keep running against the store instance they captured at load time.Because a store caches its snapshot forever —
load()returns early onceloaded === true— andmutate()persists the whole document, the stale instance's next write rolled every commit the newer instance had made back off the file:The change
mutate()re-reads the file when it has moved ahead of the cached copy, so a cached snapshot is never authority over a newer document.Guarded on revision monotonicity, so the common path (single writer, up to date) is a no-op with no extra I/O.
Why here rather than at the call sites
The plugin has ~30
store.mutate()call sites acrossexecution,session-sync,routesandtools. Fixing this by adding an "am I still the owner?" check to the long-lived services leaves the request-scoped writers (routes/tools) exposed, and quietly re-breaks the moment someone adds a new write path. The store itself is the only place that sees every writer.Verification
tests/store-staleness.spec.ts— 3 new tests: the rollback itself, the return value / no-op contract, and revision monotonicity across interleaved writers.refreshIfStale()call and re-running), so they pin real behaviour rather than restating the implementation.npm run typecheckclean;npm run buildregenerateslib/(included, sincelib/is tracked).Note on scope
This is deliberately one self-contained fix. While diagnosing it I also found two neighbouring issues —
reconcile()marking all in-flight runsfailedon every load (including hot reloads, whose agents are still alive), andisStaleClaim()measuring how long a claim has been held rather than execution silence. Both are separate; I'll open them individually rather than bundling unrelated behaviour changes here.