Skip to content

fix(store): harden JSON-file conversation store — atomic write race (#502) + list shape-guard (#501) - #512

Draft
TYRMars wants to merge 2 commits into
mainfrom
claude/vibrant-dijkstra-q1ntys
Draft

fix(store): harden JSON-file conversation store — atomic write race (#502) + list shape-guard (#501)#512
TYRMars wants to merge 2 commits into
mainfrom
claude/vibrant-dijkstra-q1ntys

Conversation

@TYRMars

@TYRMars TYRMars commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

Two related robustness fixes to packages/store/src/json-file.ts (the default JSON-file conversation store, which also backs @jarvis/observability and @jarvis/learning).

#502 — silent data loss on concurrent writes

atomicWrite derived its staging file solely from the target path (`${filePath}.tmp`), so all concurrent writers of one row shared one .tmp. Writer B truncates/rewrites A's tmp; whoever renames first consumes it, and the second rename hits ENOENT and drops that writer's turn silently. Reachable without a second socket — POST /v1/conversations/:id/messages and .../messages/stream have no in-flight guard and both catch-and-ignore the save failure, so a double-submit / retry / two tabs on one conversation loses a whole turn with nothing shown to the user.

Fix: stage to a per-write unique tmp — `${filePath}.${process.pid}.${randomUUID()}.tmp` — so each writer's rename is independent and last-write-wins holds cleanly; remove the tmp in a finally so a failed write / lost race leaves no litter.

#501 — one foreign .json permanently 500s GET /v1/conversations

list() blind-cast every parsed .json in the base dir to OnDiskConversation and dereferenced stored.messages.length with no shape guard and no per-file try/catch. A foreign-but-well-formed JSON object (e.g. JsonFileWorkspaceStore's workspaces.json flushed into the same dir, a truncated-but-parseable row, or a ..-partitioned foreign row) parses fine, has no messages array, and throws a TypeError that escapes list() and permanently 500s the endpoint — the sidebar goes empty and stays empty.

Fix: validate before dereferencing (skip when id isn't a string or messages isn't an array) and wrap the per-file body in try/catch, matching the tolerance activity-store and observability/json-file already have.

Testing

  • New packages/store/src/atomic-write.test.ts: 12 writers race one target → no ENOENT, final file is exactly one full payload (no torn write), no litter. Plus a single-writer exact-contents check.
  • New case in store.test.ts: list() skips workspaces.json, a wrong-shape row, and a top-level array without throwing; the real conversation still lists.
  • store.test.ts JSON-file contract suite: 20/20 pass.
  • pnpm --filter @jarvis/store typecheck clean; eslint clean on changed files.

Note: the SQLite-backend tests in this package fail to load in this environment because better-sqlite3's native binding couldn't be built (offline). Those failures are pre-existing and unrelated to this change.

Closes #502, #501.

🤖 Generated with Claude Code

claude added 2 commits July 22, 2026 01:26
atomicWrite derived its staging file solely from the target path
(`<path>.tmp`), so all concurrent writers of one row shared a single tmp.
Writer B truncated and rewrote A's tmp; whoever renamed first consumed
it, and the loser's rename hit ENOENT and its write was silently
dropped — reachable via double-submit / retry / two tabs on one
conversation through the catch-and-ignore REST message routes.

Make the staging name unique per write (pid + randomUUID) so each
writer's rename is independent and last-write-wins holds cleanly, and
remove the tmp in a `finally` so a failed write or lost rename race
leaves no litter. Fixes every JSON-file store plus @jarvis/observability
and @jarvis/learning, which all route writes through this helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TmFYS8i3bgR4a2JSNQUkHw
list() blind-cast every parsed .json in the base dir to OnDiskConversation
and dereferenced `stored.messages.length` with no shape guard and no
per-file try/catch. A foreign-but-well-formed JSON file (e.g.
JsonFileWorkspaceStore's workspaces.json flushed into the same dir, a
truncated-but-parseable row, or a `..`-partitioned foreign row) parses
fine, has no `messages` array, and throws a TypeError that escapes list()
and permanently 500s GET /v1/conversations — the sidebar goes empty and
stays empty.

Validate before dereferencing (skip when `id` isn't a string or
`messages` isn't an array) and wrap the per-file body in try/catch so one
bad entry is dropped rather than killing the listing — matching the
tolerance activity-store and observability/json-file already have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TmFYS8i3bgR4a2JSNQUkHw
@TYRMars TYRMars changed the title fix(store): unique per-write staging file in atomicWrite (#502) fix(store): harden JSON-file conversation store — atomic write race (#502) + list shape-guard (#501) Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants