fix: one crash-safe owner for durable state writes - #2035
Open
snimu wants to merge 26 commits into
Open
Conversation
writeFileAtomicSync (temp file + rename, optional fsync, guarded best-effort dir fsync, bounded Windows EPERM/EACCES rename retry) replaces ten hand-rolled temp+rename blocks and the three in-place auth.json writes. Every migrated call site keeps its previous durability level; auth.json gains atomicity it never had - an interrupted write can no longer truncate every stored credential - and the recovery journal's unguarded directory fsync (a hard EPERM mid-command on Windows) now uses the shared guarded variant. RES-1267
FileSettingsStorage.withLock only locked when the file already existed; on the first-write path it now re-reads under the late lock so two racing first writers compose instead of the second silently discarding the first. migrateAuthToAuthJson wrote auth.json LAST, after renaming oauth.json away and stripping settings.json - a crash between destroyed the only credential copies; the destination is now durable first and source cleanup is best-effort. RES-1267
The kernel bootstrap lock reclaimed stale locks with rm-then-mkdir: two racers could both judge the same lock stale and racer B's rm could delete the lock racer A had just acquired, yielding concurrent venv rebuilds. The supervisor launch lock already used the correct candidate-rename/rename-aside pattern; it is now extracted into tryAcquireDirLock and both sites use it, with win32's EPERM/EACCES rename-onto-directory errnos treated as collisions at both. RES-1267
A crash leaves exactly two artifact shapes in an append-only JSONL transcript: a torn tail and zero-filled bytes (size committed, page data lost). The loader silently skipped both while the appender wrote the next entry onto the same physical line - the new entry vanished and the damage self-perpetuated across crash/resume cycles. repairJsonlDamage now runs once at open: it truncates an unparseable tail, re-terminates a parseable one, strips zero-fill runs (recovering the glued record when it parses), and reports one diagnostic line. Session rewrites also go through the shared atomic writer, preserving ownership metadata via its beforeRename hook. RES-1267
acquireSessionLease tolerated only EEXIST/ENOTEMPTY on the rename collision; win32 raises EPERM/EACCES for rename-onto-directory, so any existing lease - including a stale one - threw raw instead of reaching the reclaim, wedging the session until a manual rm. Fixing that alone would have activated the second bug: readLeaseOwner collapsed every read error to undefined and undefined meant stale, so a transient EPERM/EBUSY read would have destroyed a LIVE lease. The two now land together: the collision guard accepts the win32 errnos, and an unreadable owner fails acquisition instead of reclaiming. RES-1267
HarnessState.save() truncated the state file in place; a concurrent reader catching the window saw a partial file, degraded to empty state, and could later persist that emptiness over durable knowledge. The save now writes a sibling temp file and os.replace()s it. RES-1267
Short writes loop until every byte lands (a partial temp is never renamed in); auth.json initialization uses an exclusive create so a racing initializer cannot replace credentials another process saved between the absence check and the write; the migration's auth.json write fsyncs file and directory - the destination-first ordering guarantee is otherwise hollow on journaled filesystems; dir-lock reclaim re-checks the moved lock's identity and puts back a lock that changed owners mid-reclaim; session repair resolves symlink aliases and restores ownership exactly like the rewrite path; the Python save uses a unique temp name and preserves the destination mode; the repair helper is no longer exported. RES-1267
The settings rewrite in the auth migration now preserves the file's own mode instead of recreating it umask-open (a deliberately relaxed mode also survives - nothing is silently tightened). The dir-lock owner parse only accepts positive pids: kill(0)/kill(-n) probe the caller's own process group, so a zero or negative pid in a lock read as held-forever and wedged bootstrap. The Python save creates its temp file with the destination's mode (0600 fallback) at open time, closing the umask-wide window while json.dump streams secrets. And the session repair aborts its rename when the file changed size or mtime since the snapshot - a concurrent writer's appends always win over a stale repair; the next open retries. RES-1267
parseInt accepts "123garbage" as 123, so corrupted pid-file content whose numeric prefix matched a live process read as held forever. Only an exact positive integer now names an owner; anything else is judged by the caller's staleness rule, matching garbage-is-reclaimable semantics. RES-1267
…parity The lock is now a hard-link-published FILE: the owner pid is written to a private temp and published with linkSync, so the lock is born with its content and EEXIST is the only collision signal - the EEXIST/ENOTEMPTY/EPERM/EACCES rename-interpretation matrix is gone, and an NFS-style failure report after a landed link is recovered by the classic nlink==2 verification. Reclaim keeps the identity check; a swapped-in live FILE lock is put back with a link (which cannot clobber a third acquirer). A directory at the lock path is judged as a legacy lock from the replaced protocol for one release. Session repair no longer scans every transcript at open: a bounded tail read (NUL bytes, missing trailing newline, malformed final line) gates the full scan, so clean opens of large sessions stay O(window). Atomic writers now resolve symlink aliases like the in-place writes they replaced: auth.json and harness_state.json (TS and Python) write through an alias to the real file instead of replacing the link. RES-1267
An unreadable owner (transient EPERM/EBUSY) now reports the lock held instead of judging it stale and destroying a live lock - the same absent-versus-unreadable split session leases got. A candidate-cleanup failure in the finally can no longer mask a settled acquisition as an error. And the put-back after an identity mismatch branches on the moved entry's actual file-vs-directory type instead of the judgment snapshot: linking a directory fails, which silently discarded a rival's legacy lock swapped in mid-reclaim. RES-1267
A cleanup-blocked or crashed acquirer leaks its uniquely-named candidate file; retry loops could accumulate them forever. Each acquire now does one bounded, best-effort pass over the lock's directory removing candidates older than an hour - the prefix can never match the lock itself and the age gate spares every mid-publish rival. RES-1267
The comment dedupe left empty catch blocks that the no-silent-catch guard rightly rejects; the guard outranks the trim. And an acquirer suspended past the sweep age (machine sleep) loses its candidate to a rival's litter collection between the temp write and the publish link: the ENOENT now triggers exactly one retry with a fresh candidate instead of surfacing as a raw stat failure. RES-1267
A transient stat on the moved-aside entry collapsed to "not a directory", sending a legacy dir down the link branch whose failure deleted a live lock. Probes now answer file | directory | absent | unknown, and unknown is never destructible: an unjudgeable lock reads held, an unjudgeable moved entry is restored (rename-back) or left aside - never deleted - and the publish recheck only treats a definite ENOENT as a swept candidate instead of inventing that answer from any probe failure. RES-1267
The migration's settings.json rewrite now resolves the alias like the auth and session writers do, so a dotfiles-managed settings symlink survives and the legacy apiKeys leave the REAL file. And realpathIfPresentSync now walks a dangling symlink chain to its (absent) target - in-place writes created the target through the alias, so the exclusive initializer and atomic replace must too, instead of renaming over the link. RES-1267
The judged lock is captured once as dev+ino (+shape) and every later decision keys off that capture: the aside entry is verified by inode (not re-probed content or shape), a mismatch restores by the captured shape and NEVER deletes - any restore failure leaves the entry aside - and a rename-out whose reply was lost (NFS EIO after completion) is recognized by re-probing the path and the aside's inode instead of surfacing a raw error. Zero-inode filesystems fall back to held. RES-1267
The inode-identity pin now covers pid reuse (same owner content, new inode: restored, never deleted), and the dangling-symlink walk resolves relative targets against the link's PHYSICAL parent directory, matching how the kernel resolves them for in-place writes through a symlinked directory. RES-1267
Two consistency amendments to the inode-identity reclaim: the restore operation is chosen by the ASIDE's actual shape (already known from the verify stat) instead of the judgment capture, so a file/dir swap is restored at the path rather than deterministically failing; and a lost-reply rename with a mismatched aside falls through to the normal verify - something WAS moved - restoring the swapped rival instead of reporting reclaimed and stranding it. RES-1267
Two closures on the lock reclaim. An unreadable-shape restore now attempts linkSync only - it can never replace a rival's freshly published lock (EEXIST) and fails harmlessly on a directory (EPERM), leaving the entry parked aside either way; rename-back stays reserved for a known directory. And the judged inode is pinned with an open descriptor for the whole reclaim: Linux recycles inode numbers immediately, so a rival's fresh lock could reuse the judged inode and read as identical - with the descriptor held, the number cannot be reassigned and the dev+ino identity is sound (verified 15/15 on Linux where the unpinned version failed 14/15). RES-1267
snimu
force-pushed
the
fix/atomic-persistence-owner
branch
from
September 4, 2026 16:28
2e8266f to
7637cc5
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7637cc5. Configure here.
First-run auth.json creation fchmods the descriptor to exact 0o600 - a restrictive umask (0o700) masked the open mode to 0o000 and the very next locked read failed EACCES. A benign trailing blank line no longer reads as damage, which re-ran the full repair scan on every open. The auth migration resolves a dangling auth.json symlink to its configured target instead of replacing the link. The dangling-chain walk fails loudly after 32 hops rather than silently renaming over an intermediate link (or looping on a cycle). And the inode pin descriptor closes on every exit path, including the pin-mismatch return. RES-1267
Same-shape pins table-drive (legacy pid hygiene, symlinked-auth variants, one-full-read gate cases), the two lost-reply pins merge into one two-phase test, the atomic-write pair shares one destination, and repeated scaffolding moves into seedDirLock/makeAgentDir builders. Multi-line comments collapse to one-line invariant guards. No behavior coverage changes. RES-1267
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.

A crash or a hostile Windows filesystem moment could destroy durable state across the agent: an interrupted
auth.jsonwrite truncated every stored credential (logging the user out everywhere), the auth migration renamedoauth.jsonaway and strippedsettings.jsonbefore its destination was durable, two first-time settings writers silently discarded each other, a crash-torn session transcript swallowed the next message written after resume, a transiently unreadable session-lease owner was judged stale and destroyed while live, and the kernel bootstrap lock could be stolen mid-reclaim, running two venv rebuilds concurrently.One owner for durable writes
writeFileAtomicSync(temp file beside the destination + atomic rename; options for fsync, guarded best-effort directory fsync, exact mode, and a before-rename hook for validation/ownership) replaces ten hand-rolled temp+rename blocks and three in-place writes:settings-manager,telemetry,model-registry,refinement,cron-jobs,rlm-subagent-display,command-recovery-journal(compact),daemon-supervisor(config, worker descriptors, update-restart manifest),daemon-supervisor-ownership,session-manager(full-file rewrite, ownership preserved via the hook)auth-storage(init,withLock,withLockAsync): previously in place — now atomicHarnessState.save()now writes a temp file andos.replace()s it instead of truncating in placePoint fixes on the same theme
withLockonly locked when the file existed; the first-write path now re-reads under the late lock, so racing first writers compose instead of last-writer-winsauth.jsonis durable beforeoauth.jsonis renamed orsettings.jsonrewritten; source cleanup is best-effort once the destination existsrm), andreadLeaseOwnerdistinguishes "absent" from "unreadable" — a transient read failure fails acquisition instead of reclaiming (destroying) a possibly-live leasetryAcquireDirLockrepairJsonlDamage): truncates an unparseable tail, re-terminates a parseable one, strips zero-fill runs (recovering the glued record when it parses), one diagnostic line — previously the loader hid the damage and the next append merged into the malformed line and was lostValidation
Seven pins, each proven failing on the pre-fix code:
auth.jsonmigration write leavesoauth.jsonandsettings.jsonapiKeys recoverable (pre-fix: sources destroyed first)Suites: settings-manager(+bug), auth-storage, telemetry, cron-jobs, refinement, session-lease, migrations, session-manager (all files), rlm-ledger, command-recovery-journal, daemon-runtime-lease, proper-lockfile-compromise, daemon-mode — 380+360 green locally;
npm run checkgreen; Python unittest 36/36. (auth-storage has 14 pre-existing local-env failures on the dev machine — identical on clean main — from a real Prime CLI config; unrelated.)Net src LOC
Total src: +613/−266 (net +347); tests: +795/−11 (net +784).
Mechanism: two new utils (~130 lines incl. option docs) +
repairJsonlDamage(~60). Deletion: ten duplicated temp+rename blocks, the in-place writes, the rm-then-mkdir lock, and the inline supervisor lock body.Fixes the defects reported in discussions #1765, #1431 (residual), #2009 (Python half), #1767, #1764, #1866, #1935 (rename-retry class), #1969 (part 2), #1478.
Linear: RES-1267 https://linear.app/primeintellect/issue/RES-1267
Note
Medium Risk
Touches credential storage, session transcripts, leases, and daemon/bootstrap locking—high impact if wrong, but changes are defensive with broad test coverage.
Overview
Centralizes crash-safe durable writes in
writeFileAtomicSync(temp file + rename, optional fsync/dir fsync, exact mode,beforeRenamehook, Windows rename retries) and routes auth, settings, telemetry, cron jobs, daemon supervisor state, session rewrites, migrations, and related JSON/JSONL through it—replacing in-placeauth.jsonwrites and scattered temp+rename copies.Adds
tryAcquireDirLockfor link-based directory locks with inode-safe stale reclaim; kernel bootstrap and daemon supervisor launch use it instead of rm-then-mkdir reclaim.Session JSONL now runs
repairJsonlDamageat open (bounded tail check, truncate/recover torn or zero-filled tails) so the next append is not merged into a poisoned line. Session leases treat unreadableowner.jsonas possibly live (no reclaim) and accept Windows rename collision codes.Ordering/race fixes: auth migration persists
auth.jsonbefore touchingoauth.json/settings.json; settings first-write re-reads after acquiring the lock;auth.jsoninit uses exclusive create (wx).Python
HarnessState.save()uses temp +os.replace()like the TS path.Reviewed by Cursor Bugbot for commit f0f02d2. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add crash-safe atomic writes and link-based directory locks for durable state
Introduces shared
writeFileAtomicSyncandtryAcquireDirLockutilities in atomic-file.ts and dir-lock.ts, then migrates auth storage, settings, session persistence, telemetry, cron state, daemon config, command-recovery journal, and harness state to use them.writeFileAtomicSyncwrites to an exclusive sibling temp file, handles short writes, enforces requested file mode, optionally fsyncs, runs a pre-rename validation callback, then publishes via atomic rename with unconditional temp cleanuptryAcquireDirLockuseslink-based publication with owner PID, distinguishes absent vs unreadable owners, reclaims only stale locks whose device/inode match, and renames aside stale entries rather than deleting themauth.jsonis written and persisted beforeoauth.jsonrename orsettings.jsoncleanup; a failed destination write leaves source credentials intactacquireSessionLeasenow retries on WindowsEPERM/EACCESrename failures and treats unreadableowner.jsonas held rather than stale; check session-lease.ts and bootstrap.ts for callers that previously assumed malformed lock state is reclaimableMacroscope summarized f0f02d2.