Skip to content

fix(store): never write the ledger from a snapshot older than the file - #38

Merged
cloader merged 4 commits into
cloader:mainfrom
Fishsb:fix/store-shared-file-staleness
Sep 26, 2026
Merged

cloader merged 4 commits into
cloader:mainfrom
Fishsb:fix/store-shared-file-staleness

Conversation

@Fishsb

@Fishsb Fishsb commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

What this fixes

Two TaskStore instances can point at the same ledger file. That happens in practice whenever the plugin is hot-reloaded: the previous generation's long-lived callbacks (the whenIdle() 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 once loaded === true — and mutate() persists the whole document, the stale instance's next write rolled every commit the newer instance had made back off the file:

  • no error is raised,
  • the revision does not move backwards (each instance keeps its own counter), so subscribers and the GUI observe nothing,
  • the loss only surfaces later as silently missing comments / execution records.

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.

async refreshIfStale(): Promise<boolean> {
  const parsed = JSON.parse(await readFile(this.file, 'utf8'))
  if (parsed.revision <= this.ledger.revision) return false   // already current
  this.loaded = false
  this.loadPromise = undefined
  await this.load()                                           // re-adopt via loadOnce
  return true
}

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 across execution, session-sync, routes and tools. 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.
  • Two of the three fail with this change reverted (verified by removing the refreshIfStale() call and re-running), so they pin real behaviour rather than restating the implementation.
  • Full suite: 354 passed / 25 files.
  • npm run typecheck clean; npm run build regenerates lib/ (included, since lib/ 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 runs failed on every load (including hot reloads, whose agents are still alive), and isStaleClaim() 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.

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.
@Fishsb

Fishsb commented Sep 26, 2026

Copy link
Copy Markdown
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.

@cloader
cloader merged commit fd3959e into cloader:main Sep 26, 2026
1 check passed
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 实现)。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants