Skip to content

v1.12.12 - concurrent keep records no longer drop one another

Choose a tag to compare

@github-actions github-actions released this 18 Oct 01:47
· 37 commits to main since this release
v1.12.12
9cbe008

Concurrent keep records no longer drop one another

Two keep records made at the same moment no longer drop one another; one that cannot land says record the keep again. A lock a run leaves behind is named by its own path. Nothing is removed from the plugin and no command or config key changes. The installed plugin moved in six files (git diff --stat b49cc77 HEAD -- plugin/: 6 files, 153 insertions, 33 deletions): the plugin/ twins of scripts/lib/keeps.mjs, scripts/lib/apply.mjs, scripts/lib/retier.mjs and scripts/lib/estate-archive.mjs (git diff --stat b49cc77 HEAD -- plugin/scripts/lib/: 4 files, 151 insertions, 31 deletions), the keeps paragraph of skills/coalwash/references/method.md (section 3, item 2) and its twin's copy, and the version in plugin.json.

Fixed

  • Two keep records made at the same moment no longer drop one another. recordKeepAt read the keeps store, added its entry and renamed a temp file over it with no lock, so two recorders (two sessions, or a wizard and a second agent) could each read the old store and the second rename dropped the first's entry. Each keeps store, the project's .claude/coalwash/keeps.json and the global ~/.claude/.coalwash-global-keeps.json, is now updated under a short lock of its own beside it (<store>.lock, stale window KEEPS_LOCK_STALE_MS = 5000 ms, never the 30-minute wash lock; 40 attempts 2 to 5 ms apart, nominally 0.1 to 0.2 s and about 0.6 s measured on one Windows box), and the store is re-read inside the lock, including the newer-schema refusal. A keep recorded while a wash runs does not wait for the wash lock. A recorder that cannot take the lock because another recorder holds it, or finds it taken over or unreadable before its rename, returns { ok: false, anchorDropped: false, anchorStored: false, error } with an error that names the lock and says record the keep again; it writes nothing, removes its own temp and leaves the store on disk standing. A rename that another program's open handle refuses (an indexer, antivirus or a reader holding keeps.json open: EPERM, EACCES or EBUSY, mostly Windows) is retried 10 times 50 ms apart (KEEPS_RENAME_RETRIES 9, KEEPS_RENAME_WAIT_MS 50, 0.45 s of waiting at most) with the lock re-checked before each attempt; still refused, the call returns an error naming the store and the code that says record the keep again, and the temp is removed on every failure. A lock path that is not a plain file (a link, a directory, a special file) is refused at once with an error saying what it is, spending no retry; that one needs a human to remove it and does not say record the keep again. A lock whose create fails EPERM, EACCES or EBUSY (what another recorder creating or removing the lock at the same moment looks like on Windows, and also what a folder that cannot be written looks like), or whose fresh read-back is refused, is retried inside the 40 attempts; spent, it refuses with could not be created after 40 attempts (<code>), which says record the keep again and, if it fails the same way again, to check that the folder can be written (about 0.6 s on Windows, measured on one box). A lock-create error with another code (for example EROFS) is refused at once with could not be taken (...), spends no retry and does not say record the keep again. A store that is read-only (its write bit is off) or a directory at that name, which fails the rename EPERM every time, is likewise refused at once after one lstat, with keeps write: the store ... is a directory or is read-only ..., no retry, no temp left, and the fix named (move the directory away, or clear the read-only attribute, then record the keep). A release that cannot prove the lock is its own leaves it and adds lockLeft, whose note names that lock's own stale window (5 s for a keeps lock, 30 minutes for the wash lock). Residuals: a theft landing between the lock read and the rename still goes through (a filesystem cannot check a token); a refused keep is lost until its caller records it again, including for up to 5 s after a recorder crashed holding the lock and when a reader holds the store open past about 0.45 s (references/method.md says to re-record); while a recorder waits out such a reader it holds the lock up to about half a second, so a second recorder's own lock retries can run out (it is refused with record the keep again, never lost); after a lock read-back that a passing handle refuses, the recorder keeps the lock file it just made (it cannot prove the file is its own), so its later attempts can defer behind its own lock for up to the 5 s stale window (it is refused with another recorder holds ..., record the keep again); a lock create that a genuinely unwritable folder refuses EPERM or EACCES costs the whole budget (about 0.6 s on Windows) before it refuses; a temp whose own removal fails stays, and nothing sweeps keeps.json.*.tmp; a store that a Windows ACL alone denies (its write bit is on) is not told apart from a passing handle, so it gets the passing-handle wording after the retries; a store made read-only or a directory between the one lstat and the next rename is still retried as a passing handle. — test: scripts/lib/keeps.test.mjs, the seven tests named fire 126: ... (for example fire 126: two project keep recorders interleaved inside the read-modify-write never drop one another: the second is refused while the first holds the lock, and on retry both entries stand and fire 126: a keep recorder robbed of its lock before the rename refuses and leaves the new holder's store standing) and the six tests of the follow-up, named fire 129: ... and N128-1: ... (for example fire 129: a keep record whose rename meets a reader holding the store open for a moment is retried and lands, leaving no temp and N128-1: a keeps lock path that is a directory refuses at once, says what it found, and spends no retry) and the six fire 133: ... tests (for example fire 133: a read-only keeps store refuses at once with what it is, spends no retry and leaves no temp and fire 133: a global keeps lock that cannot be created (an unwritable ~/.claude) refuses after the retry budget and says how many attempts it made) and the four fire 136: ... tests (for example fire 136: a keeps lock create refused with EPERM for a moment is retried and the keep lands)
  • A lock a run leaves behind is named by its own path, once, as its own sentence. After a finished wash, each lock left behind is flagged under its own path (the global lock was reported under the project lock's path). The note joins a refusal's error as a separate sentence, and a failed RE-TIER run prints it once instead of again on a LOCK LEFT: line; a RE-TIER run whose transaction and global lock were both left names both. No field is added or removed. — test: scripts/lib/apply.test.mjs and scripts/lib/retier.test.mjs, the tests named N125-1: ..., N125-2: ... and N125-3: ... (for example N125-3: a failed RE-TIER run prints a lock its transaction left behind once, never again on a LOCK LEFT line)