Skip to content

Fix: non-atomic writes to registry.json/tasks.json/config.json can silently lose state - #529

Open
UsryAce wants to merge 1 commit into
chaitanyagiri:mainfrom
UsryAce:fix/atomic-persistence-writes
Open

UsryAce wants to merge 1 commit into
chaitanyagiri:mainfrom
UsryAce:fix/atomic-persistence-writes

Conversation

@UsryAce

@UsryAce UsryAce commented Sep 15, 2026

Copy link
Copy Markdown

Summary

Hive.writeJson() (used by patchAgentRole, setAgentHold, renameAgent, writeTasks, and others) and persistConfig() in config.ts both write their target file in placewriteFileSync straight to the real path. A crash, forced-quit, or power loss during the write window (between truncating the file and the write finishing) leaves it truncated or invalid JSON.

The read side compounds this silently:

  • readConfig()'s catch-all falls back to { ...DEFAULTS } on any parse failure.
  • Hive.readJson() falls back to whatever empty/default value the caller passed — e.g. { godId: null, agents: {} } for the registry.

Neither surfaces an error anywhere. The visible symptom is the app quietly booting with an empty agent roster, or settings/webhooks/missions silently reset to defaults, with nothing in the logs pointing at why.

Why this is a real, not theoretical, risk

Hive already has an atomicWriteJson() (temp file + renameSync) and uses it correctly for some writes (setArchived, recordSession) — but not writeJson() itself, which is the one most call sites actually use. This app also gets force-killed in totally ordinary usage (closing the app while an agent is mid-write, a Windows update forcing a reboot, etc.) — writeConfig-adjacent calls in particular fire on nearly every settings/webhook/mission IPC handler, so the write window is exercised constantly.

Fix

  • Hive.writeJson() now delegates to the existing atomicWriteJson() logic, so every caller through it (there are several) becomes crash-safe at once, without having to hunt down and individually migrate each call site.
  • persistConfig() gets the identical temp-file+rename treatment inline (config.ts doesn't share Hive's private helper).

renameSync is atomic on the same volume, so a concurrent reader only ever observes the fully-written old file or the fully-written new one — never a partial one.

Test plan

  • npm run typecheck passes
  • npm run build succeeds
  • Maintainer/CI: this is a behavior-preserving change on the happy path (same JSON written, same final file) — the only difference is the interrupted-write case, which is inherently hard to unit test deterministically; happy to add a test that kills the process mid-writeJson if there's a preferred harness for that in this repo.

🤖 Generated with Claude Code

Before

A terminal repro isolating exactly the two write patterns in question - same file, same kill timing, only the write strategy differs (script attached as demo-atomic-write.cjs in case it's useful, not part of the diff itself):

$ node demo-atomic-write.cjs old &            # writeFileSync straight to target (the old pattern)
$ <killed with SIGKILL mid-write, same instant both times>
$ ls -la demo-target.json
-rw-r--r-- 1 Usry 197611 82837504 Sep 15 20:15 demo-target.json

$ node -e "JSON.parse(require('fs').readFileSync('demo-target.json'))"
CORRUPTED / INVALID JSON - parse error: Unterminated string in JSON at position 82837504

The target file itself - the one every reader opens - is left truncated and unparseable.

After

Identical kill timing, only the write goes through the temp-file+rename pattern this PR applies everywhere:

$ node demo-atomic-write.cjs new &             # temp file + renameSync (the new pattern)
$ <killed with SIGKILL mid-write, same instant>
$ ls -la demo-target.json
-rw-r--r-- 1 Usry 197611 18 Sep 15 20:15 demo-target.json

$ node -e "JSON.parse(require('fs').readFileSync('demo-target.json'))"
VALID JSON: {"seed":true}

$ ls -la demo-target.json.tmp-demo             # the corruption landed HERE instead
-rw-r--r-- 1 Usry 197611 49283072 Sep 15 20:15 demo-target.json.tmp-demo

The target file a reader would actually open stays valid throughout - untouched, since the kill happened before renameSync could run. The partial write lands only in an orphaned temp file that no code path ever reads.

…lently lose state

Hive.writeJson() and persistConfig() both wrote their target file in place
(writeFileSync straight to the real path). A crash, forced-quit, or power
loss during the write window - between truncating the file and finishing
the write - leaves it as truncated/invalid JSON.

The read side doesn't help here: readConfig()'s catch-all silently falls
back to `{ ...DEFAULTS }` on any parse failure, and Hive's readJson() falls
back to whatever empty/default value the caller passed, e.g. `{ godId:
null, agents: {} }` for the registry. Neither surfaces an error anywhere -
the visible symptom is the app quietly booting with an empty agent roster,
or settings/webhooks/missions reset to defaults, with nothing in the logs
pointing at why.

Hive already has an atomicWriteJson() (temp file + renameSync) used for
some writes (setArchived, recordSession) but not others - writeJson()
itself, used by patchAgentRole/setAgentHold/renameAgent/writeTasks and
several others, stayed on the unsafe path. Rather than hunting down each
individual call site, this makes writeJson() delegate to the same
atomicWriteJson() logic, so every writer through it becomes crash-safe at
once. persistConfig() in config.ts gets the identical temp-file+rename
treatment inline, since config.ts doesn't share Hive's helper.

renameSync is atomic on the same volume (true on Windows/macOS/Linux for
same-directory renames), so a concurrent reader only ever observes the
fully-written old file or the fully-written new one - never a partial one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🚫 This PR is missing its before/after evidence

Every pull request here has to show its work. Screenshots or a short screen recording, before the change and after it.

  • Before — no image or video under that heading
  • After — no image or video under that heading

How to fix it: edit the description, keep the ### Before and ### After headings from the template, and drag an image or video under each. GitHub uploads it inline. This check re-runs the moment you save.

A bug fix with no visible surface still needs it: show the failing behaviour, then the same steps passing. A terminal recording is fine.

Genuinely nothing to show — a CI tweak, a typo, a dependency bump? A maintainer can apply the no-visual-change label. Please don't ask unless it truly has no observable effect.

📖 CONTRIBUTING.md → Evidence is mandatory

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.

1 participant