@cruxy/cli 1.11.4 → 1.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1247,6 +1247,70 @@ export function memoryInvalid(detail) {
1247
1247
  ],
1248
1248
  });
1249
1249
  }
1250
+ /**
1251
+ * A memory write was refused because the scope's existing store could not be
1252
+ * loaded, so the write would have replaced everything it held with the one
1253
+ * new note and reported success (cli#327). The same shape as
1254
+ * {@link credentialsUnprotected}: nothing is written, the file is named, and
1255
+ * the next step depends on WHICH state the file is in —
1256
+ *
1257
+ * - `unreadable`: the bytes could not be read at all (permissions, a
1258
+ * directory at the path). Fix the path; the content is presumably intact.
1259
+ * - `invalid`: the bytes were read but are not a memory document — a
1260
+ * half-written file (a crash or a concurrent writer mid-write) or a hand
1261
+ * edit that broke the JSON or the shape. Repair or clear it.
1262
+ *
1263
+ * An ABSENT file is not this error: it is the one state where starting empty
1264
+ * is correct, and conflating it with these two is what caused the wipe.
1265
+ */
1266
+ export function memoryStoreUnloadable(scope, state, path, detail, underlying) {
1267
+ const title = state === "unreadable"
1268
+ ? `refusing to write memory over a store that could not be read: ${path}`
1269
+ : `refusing to write memory over a store that is not a valid memory file: ${path}`;
1270
+ const nextSteps = state === "unreadable"
1271
+ ? [
1272
+ "nothing was written — the existing notes are presumably intact, but they could not be read",
1273
+ `make ${path} a regular file readable and writable by you, then retry`,
1274
+ ]
1275
+ : [
1276
+ "nothing was written — saving would have replaced every note the file holds",
1277
+ `inspect ${path}: a half-written or hand-edited file; restore it from a backup or your editor, then retry`,
1278
+ `or start the scope empty with \`cruxy memory clear --scope ${scope}\` (this discards whatever the file held)`,
1279
+ ];
1280
+ return new CruxyError({
1281
+ code: ErrorCode.MemoryStoreUnloadable,
1282
+ title,
1283
+ cause: detail,
1284
+ nextSteps,
1285
+ underlying,
1286
+ meta: { path, scope, state },
1287
+ });
1288
+ }
1289
+ /**
1290
+ * A store write waited {@link STORE_LOCK_WAIT_MS}-ish for the cross-process
1291
+ * lock and a live process still held it (cli#304). Nothing was written. The
1292
+ * holder is named when its stamp could be read; an unreadable stamp (a holder
1293
+ * killed between creating and writing the lock file, microseconds apart) is
1294
+ * the one case the user may need to remove the file by hand — so the path is
1295
+ * in the message.
1296
+ */
1297
+ export function storeLocked(file, lockFile, holder) {
1298
+ const who = holder
1299
+ ? `held by pid ${holder.pid}, started ${holder.startedAt}`
1300
+ : `held by a process whose stamp could not be read (${lockFile})`;
1301
+ return new CruxyError({
1302
+ code: ErrorCode.StoreLocked,
1303
+ title: `another cruxy is writing ${file}`,
1304
+ cause: who,
1305
+ nextSteps: holder
1306
+ ? ["retry in a moment — the other write takes milliseconds"]
1307
+ : [
1308
+ `if no other cruxy is running, remove ${lockFile} and retry`,
1309
+ "retry in a moment otherwise",
1310
+ ],
1311
+ meta: { path: file, lockFile, holderPid: holder?.pid },
1312
+ });
1313
+ }
1250
1314
  // ── usage telemetry (exit 2) — C.22 ───────────────────────────────────────────
1251
1315
  /**
1252
1316
  * The local usage store (`~/.cruxy/usage/runs.json`) is corrupt or unreadable
@@ -172,6 +172,16 @@ export const ErrorCode = {
172
172
  /** A memory write was refused because the content matched a secret shape —
173
173
  * secrets are never persisted (defense in depth over C.17). */
174
174
  MemorySecret: "CRUXY_E_MEMORY_SECRET",
175
+ /** A memory write was refused because the scope's existing store could not
176
+ * be loaded — unreadable, or readable but not a valid memory document (a
177
+ * torn or hand-broken file). Writing would replace everything it held with
178
+ * the one new note and report success (cli#327). Nothing is written. */
179
+ MemoryStoreUnloadable: "CRUXY_E_MEMORY_STORE_UNLOADABLE",
180
+ /** A store write (memory, usage, trust) waited its bounded time for the
181
+ * cross-process store lock and another LIVE cruxy still held it (cli#304).
182
+ * Nothing was written; the holder is named. A stale lock never produces
183
+ * this — a dead holder's lock is taken, not waited on. */
184
+ StoreLocked: "CRUXY_E_STORE_LOCKED",
175
185
  // usage telemetry (exit 2) — C.22
176
186
  /** The local usage store is corrupt/unreadable — the read is SKIPPED and this
177
187
  * is surfaced; never fatal to a run (usage display is best-effort). */
@@ -388,6 +398,8 @@ const EXIT_CODES = {
388
398
  [ErrorCode.MemoryInvalid]: 14,
389
399
  [ErrorCode.MemoryUntrusted]: 14,
390
400
  [ErrorCode.MemorySecret]: 14,
401
+ [ErrorCode.MemoryStoreUnloadable]: 14,
402
+ [ErrorCode.StoreLocked]: 14,
391
403
  // Usage telemetry (C.22). A corrupt store is a usage/data problem the user can
392
404
  // fix (delete the file); it shares the usage exit code and is never fatal to a
393
405
  // run — the aggregation just skips it.
@@ -58,7 +58,11 @@ export const rememberTool = {
58
58
  // Fail loud to the model: a refused secret / invalid note is reported with
59
59
  // its stable code so the model knows it was NOT stored (never a silent no-op).
60
60
  if (CruxyError.is(err)) {
61
- return { ok: false, error: `${err.code}: ${err.title}` };
61
+ // The next steps ride along so the model can tell the user what to do
62
+ // (cli#327: "refusing to write over a store that could not be read"
63
+ // is only actionable with the file and the fix named).
64
+ const steps = err.nextSteps.length > 0 ? ` — ${err.nextSteps.join("; ")}` : "";
65
+ return { ok: false, error: `${err.code}: ${err.title}${steps}` };
62
66
  }
63
67
  return { ok: false, error: err.message };
64
68
  }
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
- import { memoryInvalid, memorySecretRefused } from "../errors/index.js";
3
+ import { memoryInvalid, memorySecretRefused, memoryStoreUnloadable, } from "../errors/index.js";
4
+ import { withStoreLock } from "../utils/store-lock.js";
4
5
  import { buildRecallBlock } from "./recall.js";
5
6
  import { containsSecret } from "./secrets.js";
6
7
  import { defaultMemorySources, loadScope, saveScope, } from "./store.js";
@@ -78,20 +79,25 @@ export class MemoryService {
78
79
  createdAt: this.now(),
79
80
  };
80
81
  const file = this.sources[scope];
81
- const existing = loadScope(file, scope).entries;
82
- if (scope === "project") {
83
- // Snapshot trust BEFORE the write — this is the launder guard.
84
- const wasTrustedOrEmpty = existing.length === 0 || this.projectTrusted(existing);
85
- const next = [...existing, entry];
86
- saveScope(file, next, scope);
87
- // Only extend trust when we started clean; never over foreign untrusted
88
- // entries.
89
- if (wasTrustedOrEmpty)
90
- this.recordProjectTrust(next);
91
- }
92
- else {
93
- saveScope(file, [...existing, entry], scope);
94
- }
82
+ // The whole read-modify-write under the cross-process lock (cli#304): a
83
+ // second cruxy's `remember` on the same file waits for this one instead
84
+ // of loading a set this write is about to replace.
85
+ withStoreLock(file, () => {
86
+ const existing = this.loadForWrite(file, scope).entries;
87
+ if (scope === "project") {
88
+ // Snapshot trust BEFORE the write this is the launder guard.
89
+ const wasTrustedOrEmpty = existing.length === 0 || this.projectTrusted(existing);
90
+ const next = [...existing, entry];
91
+ saveScope(file, next, scope);
92
+ // Only extend trust when we started clean; never over foreign untrusted
93
+ // entries.
94
+ if (wasTrustedOrEmpty)
95
+ this.recordProjectTrust(next);
96
+ }
97
+ else {
98
+ saveScope(file, [...existing, entry], scope);
99
+ }
100
+ });
95
101
  return entry;
96
102
  }
97
103
  /** A snapshot of both scopes + project trust, for `cruxy memory list`. */
@@ -113,15 +119,19 @@ export class MemoryService {
113
119
  forget(id) {
114
120
  for (const scope of ["user", "project"]) {
115
121
  const file = this.sources[scope];
116
- const entries = loadScope(file, scope).entries;
117
- const kept = entries.filter((e) => e.id !== id);
118
- if (kept.length === entries.length)
119
- continue;
120
- const wasTrusted = scope === "project" && this.projectTrusted(entries);
121
- saveScope(file, kept, scope);
122
- if (wasTrusted)
123
- this.recordProjectTrust(kept);
124
- return true;
122
+ const removed = withStoreLock(file, () => {
123
+ const entries = this.loadForWrite(file, scope).entries;
124
+ const kept = entries.filter((e) => e.id !== id);
125
+ if (kept.length === entries.length)
126
+ return false;
127
+ const wasTrusted = scope === "project" && this.projectTrusted(entries);
128
+ saveScope(file, kept, scope);
129
+ if (wasTrusted)
130
+ this.recordProjectTrust(kept);
131
+ return true;
132
+ });
133
+ if (removed)
134
+ return true;
125
135
  }
126
136
  return false;
127
137
  }
@@ -131,15 +141,21 @@ export class MemoryService {
131
141
  let removed = 0;
132
142
  for (const s of scopes) {
133
143
  const file = this.sources[s];
134
- const entries = loadScope(file, s).entries;
135
- removed += entries.length;
136
- saveScope(file, [], s);
137
- // Clearing project memory to empty: re-anchor trust to the empty set so a
138
- // later `remember` starts from a clean, trusted baseline (empty memory is
139
- // never recalled anyway).
140
- if (s === "project" && this.projectTrusted(entries)) {
141
- this.recordProjectTrust([]);
142
- }
144
+ withStoreLock(file, () => {
145
+ // Deliberately NOT `loadForWrite`: clearing is the documented recovery
146
+ // for a store that cannot be loaded (cli#327). Emptying it is the
147
+ // user's explicit intent here, so an unloadable file counts as 0
148
+ // removed and is replaced with an empty document.
149
+ const entries = loadScope(file, s).entries;
150
+ removed += entries.length;
151
+ saveScope(file, [], s);
152
+ // Clearing project memory to empty: re-anchor trust to the empty set
153
+ // so a later `remember` starts from a clean, trusted baseline (empty
154
+ // memory is never recalled anyway).
155
+ if (s === "project" && this.projectTrusted(entries)) {
156
+ this.recordProjectTrust([]);
157
+ }
158
+ });
143
159
  }
144
160
  return removed;
145
161
  }
@@ -150,9 +166,30 @@ export class MemoryService {
150
166
  * become recallable.
151
167
  */
152
168
  trustProject() {
153
- const entries = loadScope(this.sources.project, "project").entries;
154
- this.recordProjectTrust(entries);
155
- return entries.length;
169
+ // Trust is a fingerprint over the entry SET. Recording it for the empty
170
+ // set an unloadable file reads as would trust nothing and, once the file
171
+ // is repaired, mismatch its real content — so this refuses like a write.
172
+ return withStoreLock(this.sources.project, () => {
173
+ const entries = this.loadForWrite(this.sources.project, "project").entries;
174
+ this.recordProjectTrust(entries);
175
+ return entries.length;
176
+ });
177
+ }
178
+ /**
179
+ * Load a scope for a path that will WRITE the whole set back (cli#327).
180
+ * `absent` and `loaded` are fine; `unreadable` and `invalid` are refused
181
+ * with `CRUXY_E_MEMORY_STORE_UNLOADABLE`, because the "existing" set is
182
+ * empty only because the load failed, and saving it back would replace every
183
+ * note the file holds with whatever this call adds — while reporting success.
184
+ * A read-only path (`recall`, `status`) keeps using `loadScope` directly and
185
+ * surfaces the same condition as an error row instead.
186
+ */
187
+ loadForWrite(file, scope) {
188
+ const load = loadScope(file, scope);
189
+ if (load.file.kind === "unreadable" || load.file.kind === "invalid") {
190
+ throw memoryStoreUnloadable(scope, load.file.kind, file, load.file.message, load.file.underlying);
191
+ }
192
+ return load;
156
193
  }
157
194
  recordProjectTrust(entries) {
158
195
  this.trust.record({
@@ -1,6 +1,7 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globalDir } from "../config/paths.js";
4
+ import { existingMode, replaceStoreFile } from "../utils/store-file.js";
4
5
  import { GLOBAL_DIR_NAME, MEMORY_DIR_NAME, MEMORY_FILE_NAME, } from "../constants.js";
5
6
  import { containsSecret } from "./secrets.js";
6
7
  import { MemoryEntrySchema, MemoryFileSchema, MEMORY_FILE_VERSION, } from "./types.js";
@@ -13,46 +14,60 @@ export function defaultMemorySources(cwd) {
13
14
  };
14
15
  }
15
16
  /**
16
- * Read and validate one scope's file. A missing file yields an empty result
17
- * (not an error). Each entry is (1) schema-validated and (2) scanned for secret
18
- * content; failures are excluded and collected. The returned `entries` all carry
19
- * the requested `scope` (the on-disk `scope` field is normalized to it, so a
20
- * mislabeled entry can't cross scopes).
17
+ * Read and validate one scope's file. Each entry is (1) schema-validated and
18
+ * (2) scanned for secret content; failures are excluded and collected. The
19
+ * returned `entries` all carry the requested `scope` (the on-disk `scope` field
20
+ * is normalized to it, so a mislabeled entry can't cross scopes).
21
+ *
22
+ * THE FILE-LEVEL STATE IS REPORTED SEPARATELY (`file`, cli#327), because three
23
+ * different situations used to collapse into "empty, carry on":
24
+ *
25
+ * - a MISSING file (ENOENT) is `absent` — the one state where starting from
26
+ * an empty set is right;
27
+ * - any OTHER read failure (EACCES, EISDIR, ...) is `unreadable` — something
28
+ * is there and we could not see it;
29
+ * - bytes that are not a memory document (not JSON, or the wrong shape) are
30
+ * `invalid` — something was there and it is damaged, or half-written.
31
+ *
32
+ * Readers (recall, `cruxy memory list`) still get `entries: []` plus an error
33
+ * row for the last two, so nothing that only READS changes behaviour. Writers
34
+ * must branch on `file.kind`: see {@link saveScope}'s contract and
35
+ * `MemoryService.remember`.
21
36
  */
22
37
  export function loadScope(file, scope) {
23
38
  let raw;
24
39
  try {
25
40
  raw = readFileSync(file, "utf8");
26
41
  }
27
- catch {
28
- // Missing / unreadable → nothing recalled from this scope (not an error).
29
- return { entries: [], errors: [] };
42
+ catch (err) {
43
+ if (err.code === "ENOENT") {
44
+ // Missing nothing recalled from this scope, and a write may create it.
45
+ return { entries: [], errors: [], file: { kind: "absent" } };
46
+ }
47
+ // Present but unreadable. NOT absent: a writer that treated it as absent
48
+ // would replace whatever is there.
49
+ const message = `unreadable: ${err.message}`;
50
+ return {
51
+ entries: [],
52
+ errors: [{ scope, reason: "unreadable", id: "(file)", message }],
53
+ file: { kind: "unreadable", message, underlying: err },
54
+ };
30
55
  }
31
56
  let parsedFile;
32
57
  try {
33
58
  parsedFile = JSON.parse(raw);
34
59
  }
35
- catch {
36
- return {
37
- entries: [],
38
- errors: [
39
- { scope, reason: "invalid", id: "(file)", message: "not valid JSON" },
40
- ],
41
- };
60
+ catch (err) {
61
+ // Read fine, but not JSON: a half-written file (a crash or another
62
+ // writer mid-`writeFileSync`) or a hand edit. Either way it HELD something.
63
+ const message = raw.length === 0
64
+ ? "not valid JSON (the file is empty a truncated or half-written store)"
65
+ : "not valid JSON (a half-written or hand-edited store)";
66
+ return invalidFile(scope, message, err);
42
67
  }
43
68
  const fileResult = MemoryFileSchema.safeParse(parsedFile);
44
69
  if (!fileResult.success) {
45
- return {
46
- entries: [],
47
- errors: [
48
- {
49
- scope,
50
- reason: "invalid",
51
- id: "(file)",
52
- message: `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`,
53
- },
54
- ],
55
- };
70
+ return invalidFile(scope, `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`);
56
71
  }
57
72
  const entries = [];
58
73
  const errors = [];
@@ -86,15 +101,46 @@ export function loadScope(file, scope) {
86
101
  // move an entry across scopes).
87
102
  entries.push({ ...parsed.data, scope });
88
103
  });
89
- return { entries, errors };
104
+ return { entries, errors, file: { kind: "loaded" } };
105
+ }
106
+ function invalidFile(scope, message, underlying) {
107
+ const file = { kind: "invalid", message, underlying };
108
+ return {
109
+ entries: [],
110
+ errors: [{ scope, reason: "invalid", id: "(file)", message }],
111
+ file,
112
+ };
90
113
  }
91
114
  /**
92
- * Persist a scope's entries, overwriting the file. Creates the memory dir if
93
- * needed. The user scope is written `0600` (it is personal, cross-project data);
94
- * the project scope inherits normal repo permissions (it may be committed).
115
+ * Persist a scope's entries, replacing the file. Creates the memory dir if
116
+ * needed. The user scope is written `0600` (it is personal, cross-project data).
117
+ * The project scope KEEPS THE MODE THE FILE ALREADY HAD, and takes the process
118
+ * umask only when creating it: a replace through rename is a new inode, and
119
+ * letting it land at whatever umask says would be a permission change the user
120
+ * never asked for, arriving through a write path — the same reason `api.env`'s
121
+ * mode has to survive a `sed`.
122
+ *
123
+ * WRITTEN TO A TEMP FILE AND RENAMED INTO PLACE (cli#327) through
124
+ * `utils/store-file.ts`, which also checks the cross-process store lock right
125
+ * before the rename (cli#304). A plain `writeFileSync` on an existing path truncates first and
126
+ * fills in after, so anything reading in between — recall in a second cruxy,
127
+ * `cruxy memory list`, or another writer's load — saw an empty or half-written
128
+ * file. `rename(2)` swaps the whole document in one step: a reader sees the
129
+ * old bytes or the new ones, never a prefix. It does NOT serialize two writers
130
+ * (that is #304, the read-modify-write race); it only guarantees that what
131
+ * either of them reads is a complete document.
132
+ *
133
+ * CONTRACT FOR CALLERS: this overwrites whatever is at `file`. It must only be
134
+ * called with a set derived from a load whose `file.kind` was `loaded` or
135
+ * `absent` — never `unreadable` or `invalid`, where the caller's "existing"
136
+ * set is empty only because the load failed. `MemoryService` enforces that;
137
+ * `clear` is the deliberate exception (it is the documented recovery).
95
138
  */
96
139
  export function saveScope(file, entries, scope) {
97
140
  mkdirSync(path.dirname(file), { recursive: true });
98
141
  const body = JSON.stringify({ version: MEMORY_FILE_VERSION, entries }, null, 2);
99
- writeFileSync(file, body, scope === "user" ? { mode: 0o600 } : undefined);
142
+ // The mode the replacement must carry: explicit 0600 for the user scope;
143
+ // for the project scope, whatever the existing file has (absent → umask).
144
+ const mode = scope === "user" ? 0o600 : existingMode(file);
145
+ replaceStoreFile(file, body, mode);
100
146
  }
@@ -1,5 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync } from "node:fs";
3
+ import { replaceStoreFile } from "../utils/store-file.js";
4
+ import { withStoreLock } from "../utils/store-lock.js";
3
5
  import path from "node:path";
4
6
  import { globalDir } from "../config/paths.js";
5
7
  import { MEMORY_TRUST_FILE_NAME } from "../constants.js";
@@ -57,20 +59,21 @@ export function isMemoryTrusted(store, root, currentFingerprint) {
57
59
  */
58
60
  export function fileMemoryTrustStore(file = memoryTrustPath()) {
59
61
  let cache = null;
60
- const load = () => {
61
- if (cache)
62
- return cache;
62
+ const readFresh = () => {
63
63
  try {
64
64
  const raw = JSON.parse(readFileSync(file, "utf8"));
65
- cache =
66
- raw && typeof raw === "object"
67
- ? raw
68
- : {};
65
+ return raw && typeof raw === "object"
66
+ ? raw
67
+ : {};
69
68
  }
70
69
  catch {
71
70
  // Missing or corrupt → no trust (fail-closed).
72
- cache = {};
71
+ return {};
73
72
  }
73
+ };
74
+ const load = () => {
75
+ if (!cache)
76
+ cache = readFresh();
74
77
  return cache;
75
78
  };
76
79
  return {
@@ -78,16 +81,20 @@ export function fileMemoryTrustStore(file = memoryTrustPath()) {
78
81
  return load()[path.resolve(root)];
79
82
  },
80
83
  record(trust) {
81
- const store = load();
82
- store[path.resolve(trust.root)] = {
83
- ...trust,
84
- root: path.resolve(trust.root),
85
- };
86
- mkdirSync(path.dirname(file), { recursive: true });
87
- // 0600: trust records name local paths; keep them owner-only.
88
- writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
89
- if (existsSync(file))
84
+ // Under the cross-process lock, from a FRESH read (cli#304): the cached
85
+ // document may predate another cruxy's record for a different root, and
86
+ // writing the cache back would drop that root's trust. Atomic replace,
87
+ // `0600`: trust records name local paths; keep them owner-only.
88
+ withStoreLock(file, () => {
89
+ const store = readFresh();
90
+ store[path.resolve(trust.root)] = {
91
+ ...trust,
92
+ root: path.resolve(trust.root),
93
+ };
94
+ mkdirSync(path.dirname(file), { recursive: true });
95
+ replaceStoreFile(file, JSON.stringify(store, null, 2), 0o600);
90
96
  cache = store;
97
+ });
91
98
  },
92
99
  };
93
100
  }
@@ -1,8 +1,10 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { globalDir } from "../config/paths.js";
4
4
  import { USAGE_DIR_NAME, USAGE_FILE_NAME } from "../constants.js";
5
- import { usageRead } from "../errors/index.js";
5
+ import { CruxyError, usageRead } from "../errors/index.js";
6
+ import { existingMode, replaceStoreFile } from "../utils/store-file.js";
7
+ import { withStoreLock } from "../utils/store-lock.js";
6
8
  import { UsageFileSchema, USAGE_FILE_VERSION, } from "./types.js";
7
9
  /**
8
10
  * The usage store (C.22): a single JSON file under `~/.cruxy/usage`, holding a
@@ -62,6 +64,20 @@ export function loadUsage(file = usageStorePath()) {
62
64
  */
63
65
  export function appendRun(record, opts = { retention: 50 }) {
64
66
  const file = opts.file ?? usageStorePath();
67
+ // The whole read-modify-write under the cross-process lock (cli#304), so two
68
+ // runs finishing at once queue instead of one dropping the other's record.
69
+ // `never throws` still holds: a lock held past its deadline by a live cruxy
70
+ // comes back as the coded error for the caller to downgrade.
71
+ try {
72
+ return withStoreLock(file, () => appendRunLocked(record, opts, file));
73
+ }
74
+ catch (err) {
75
+ if (CruxyError.is(err))
76
+ return { error: err };
77
+ throw err;
78
+ }
79
+ }
80
+ function appendRunLocked(record, opts, file) {
65
81
  // A corrupt existing file is NOT overwritten — surface it and skip, rather
66
82
  // than silently destroying whatever the user has (or blindly appending to a
67
83
  // shape we couldn't validate).
@@ -80,7 +96,9 @@ export function appendRun(record, opts = { retention: 50 }) {
80
96
  // the data, just one write later. version/runs are re-stated after the
81
97
  // spread so this build's own values always win.
82
98
  const body = JSON.stringify({ ...loaded.data, version: USAGE_FILE_VERSION, runs: pruned }, null, 2);
83
- writeFileSync(file, body, { mode: 0o600 });
99
+ // Atomic replace through the shared helper; `0600` on create, the file's
100
+ // own bits on a replace (a rename is a new inode; see store-file.ts).
101
+ replaceStoreFile(file, body, existingMode(file) ?? 0o600);
84
102
  return {};
85
103
  }
86
104
  catch (err) {
@@ -0,0 +1,51 @@
1
+ import { chmodSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
+ import { assertStoreLockHeld } from "./store-lock.js";
3
+ /**
4
+ * Replace a store document atomically (cli#304): write a temp file beside it,
5
+ * pin the mode, verify the store lock is still ours, rename into place. The
6
+ * one copy of the pattern `memory/store.ts`, `usage/store.ts` and
7
+ * `memory/trust.ts` share; `config/credentials.ts` keeps its own because it
8
+ * also enforces an owner-only ACL on the temp.
9
+ *
10
+ * `rename(2)` swaps a complete document in one step, so a reader — or another
11
+ * writer's load — sees the old bytes or the new ones, never a truncated or
12
+ * spliced file. The lock check sits immediately before the rename because the
13
+ * rename is the commit: if the lock was taken from under us, nothing lands
14
+ * and `withStoreLock` retries the whole read-modify-write from a fresh load.
15
+ *
16
+ * `mode` is chmod'd explicitly: `writeFileSync`'s mode is masked by the umask
17
+ * and applies only on create, and the temp IS a create. A rename is a new
18
+ * inode, so without this a replace would silently change the file's
19
+ * permission bits — a change the user never asked for, arriving through a
20
+ * write path. Callers decide the bits (explicit `0600` for personal data,
21
+ * {@link existingMode} to preserve what a file already has).
22
+ */
23
+ export function replaceStoreFile(file, body, mode) {
24
+ const tmp = `${file}.tmp-${process.pid}`;
25
+ try {
26
+ writeFileSync(tmp, body, mode !== undefined ? { mode } : undefined);
27
+ if (mode !== undefined)
28
+ chmodSync(tmp, mode);
29
+ assertStoreLockHeld(file);
30
+ renameSync(tmp, file);
31
+ }
32
+ catch (err) {
33
+ try {
34
+ rmSync(tmp, { force: true });
35
+ }
36
+ catch {
37
+ // The temp may already be gone; the target file is untouched regardless.
38
+ }
39
+ throw err;
40
+ }
41
+ }
42
+ /** The permission bits of `file`, or `undefined` when there is nothing there
43
+ * to preserve (the create case takes the caller's default). */
44
+ export function existingMode(file) {
45
+ try {
46
+ return statSync(file).mode & 0o777;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
@@ -0,0 +1,324 @@
1
+ import { mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { performance } from "node:perf_hooks";
4
+ import { storeLocked } from "../errors/index.js";
5
+ import { describeOwner, pidAlive, selfStamp, } from "./process-owner.js";
6
+ /**
7
+ * An exclusive, cross-process lock around a store's read-modify-write span
8
+ * (cli#304).
9
+ *
10
+ * WHY A LOCK AND NOT A COMPARE. The memory, usage and trust stores are each
11
+ * one JSON document: load, mutate, replace. The span is synchronous, so
12
+ * nothing in one process can interleave it — but a second cruxy in the same
13
+ * project can, and when it does the later writer's load predates the earlier
14
+ * writer's rename and the earlier record is gone, while both callers were
15
+ * told it was saved. Measured: ~50 % of writes lost under a two-process tight
16
+ * loop, ~span ÷ gap otherwise. A compare-and-swap on the loaded bytes was
17
+ * measured too and still lost a third of them, because both writers pass the
18
+ * compare before either renames. Only mutual exclusion closed it: zero lost
19
+ * across every trial, at 0.5–2 ms per write.
20
+ *
21
+ * THE LOCK IS A FILE created with `wx` (`O_CREAT | O_EXCL`) beside the store,
22
+ * holding this process's {@link ProcessStamp}. Creation is the atomic step;
23
+ * whoever's `open` succeeds holds the lock. A contender spins for at most
24
+ * {@link STORE_LOCK_WAIT_MS} and then refuses with `CRUXY_E_STORE_LOCKED`
25
+ * naming the holder — never silently proceeds.
26
+ *
27
+ * STALENESS IS P1's RULE, NOT A NEW ONE. The stamp is a pid plus the start
28
+ * token `utils/process-owner.ts` reads from the OS for that pid, and
29
+ * {@link describeOwner} decides: a stamp whose process is gone, or whose pid
30
+ * was recycled onto a different start time, is `stale`, and a stale lock is
31
+ * not a holder — it is removed and taken. Nothing is cleaned up for
32
+ * correctness; a crash leaves a stamp that the next writer recognises as dead
33
+ * the moment it reads it. This is the same rule the session owner file uses,
34
+ * chosen in P1 because a lock that outlives its crash is worse than no lock.
35
+ *
36
+ * ONE RESIDUAL, AND THE CHECK THAT COVERS IT. Breaking a stale lock is
37
+ * read-then-unlink, two syscalls; two contenders that both read the same
38
+ * dead stamp can both unlink, and the second unlink can remove the first's
39
+ * freshly created lock. So a holder records its lock file's inode, and
40
+ * {@link assertStoreLockHeld} — called by the store right before its rename —
41
+ * re-stats the lock: a missing or different inode means the lock was taken
42
+ * from under us, and the write is NOT committed. The error is caught here
43
+ * and the whole read-modify-write is retried from a fresh load. Retried, not
44
+ * refused: there is no approval in a store write, the operation is a
45
+ * function of the current state, so a retry produces exactly the right
46
+ * result and a refusal would fail a valid append.
47
+ *
48
+ * A stamp that cannot be parsed (a holder killed between `open` and
49
+ * `write`, microseconds apart) has no pid to judge and is treated as held
50
+ * until the deadline, then refused with the path so the user can remove it.
51
+ * Inventing a second liveness rule (mtime, age) for that case would be worse
52
+ * than the refusal.
53
+ *
54
+ * WIN32 REPORTS CONTENTION AS EPERM, NOT EEXIST. Unlinking a file another
55
+ * process still has open does not remove it there; it becomes delete-pending
56
+ * until the last handle closes, and during that window a create on the same
57
+ * name fails with `EPERM` (ERROR_ACCESS_DENIED), as does opening it to read.
58
+ * A releasing holder and a contender mid-`readStamp` hit exactly that window
59
+ * on every burst. So `EPERM`, `EACCES` and `EBUSY` from the create or the
60
+ * read are the same event as `EEXIST` — someone else is at the lock — and
61
+ * are spun on, not thrown. A real permission problem still surfaces: if the
62
+ * deadline passes without a holder ever having been read, the last such
63
+ * error is thrown as itself rather than dressed up as `CRUXY_E_STORE_LOCKED`.
64
+ * Found by CI: the three contention tests passed on darwin and linux and
65
+ * failed on windows-latest with `EPERM open runs.json.lock`.
66
+ */
67
+ /** Errors a create/read/unlink on the lock file can return while another
68
+ * process is at it (see WIN32 in the module comment). */
69
+ const CONTENTION_CODES = new Set(["EEXIST", "EPERM", "EACCES", "EBUSY"]);
70
+ function isContention(err) {
71
+ return CONTENTION_CODES.has(err.code ?? "");
72
+ }
73
+ /**
74
+ * How long a contender waits for a held lock before refusing.
75
+ *
76
+ * Measured spans are 0.5–3 ms, and 250 ms was the first figure: two orders
77
+ * of magnitude of headroom on an idle machine. On a SATURATED one it is not
78
+ * enough — a holder that is descheduled mid-span holds the lock for as long
79
+ * as the scheduler leaves it off-CPU, and with the full test suite running in
80
+ * parallel the two-process memory test refused writes at 250 ms in one of two
81
+ * runs. The budget bounds a wait, not a span; nothing on the success path
82
+ * costs more with a larger one. Two seconds is still a bounded, visible
83
+ * refusal, and it was not reached under that same load.
84
+ *
85
+ * Do not tighten this back toward the measured span for tidiness: a smaller
86
+ * figure buys nothing on the success path and turns scheduler jitter into a
87
+ * refused write. Only lower it with a new measurement under full-suite load.
88
+ */
89
+ export const STORE_LOCK_WAIT_MS = 2000;
90
+ /** How many times a lost-lock retry is attempted before the error propagates. */
91
+ const MAX_LOST_LOCK_RETRIES = 3;
92
+ const SPIN_MS = 1;
93
+ /** How long a waiter polls without sleeping before it backs off to
94
+ * {@link SPIN_MS} sleeps. A holder in a burst releases and re-creates the lock
95
+ * microseconds apart; a sleeping poller misses that window almost every time,
96
+ * a busy poller catches it. Costs CPU only while contended. */
97
+ const BUSY_POLL_MS = 3;
98
+ /**
99
+ * FAIRNESS. A process writing the same store in a burst releases the lock and
100
+ * re-creates it microseconds later, while a contender polls once per
101
+ * {@link SPIN_MS}; measured, that starves the contender for the whole wait
102
+ * (1–2 refusals per 2000 writes under a two-process tight loop) even though
103
+ * every individual hold is a millisecond. So a process that released a lock
104
+ * within the last poll interval yields one interval before taking it again:
105
+ * an isolated write pays nothing, a burst pays a millisecond per write, and a
106
+ * waiter always gets a window. Polls are jittered so contenders do not
107
+ * phase-lock on the holder's cycle.
108
+ */
109
+ const lastReleased = new Map();
110
+ /**
111
+ * Liveness verdicts by the exact stamp bytes seen on disk. {@link describeOwner}
112
+ * reads the holder's start token from the OS — on darwin that is one `ps`
113
+ * subprocess (~10 ms), on win32 a PowerShell call — so it is asked ONCE per
114
+ * distinct stamp, not once per spin. Later spins on the same stamp re-check
115
+ * only the cheap `pidAlive` signal: a holder that exits mid-wait is noticed
116
+ * within a spin, and a recycled pid cannot fool this because the stamp bytes
117
+ * (pid + token) would differ and be judged afresh.
118
+ */
119
+ const verdicts = new Map();
120
+ /** The lock file beside a store file. */
121
+ export function storeLockPath(file) {
122
+ return `${path.resolve(file)}.lock`;
123
+ }
124
+ /** Thrown by {@link assertStoreLockHeld} when the lock we hold is gone or is
125
+ * someone else's; caught by {@link withStoreLock}, which retries `fn`. */
126
+ export class StoreLockLostError extends Error {
127
+ constructor(file) {
128
+ super(`store lock for ${file} was taken by another process before the write committed`);
129
+ this.name = "StoreLockLostError";
130
+ }
131
+ }
132
+ /** Locks this process currently holds, by resolved store path. */
133
+ const held = new Map();
134
+ /**
135
+ * Run `fn` holding the exclusive lock for `file`. Re-entrant within the
136
+ * process for the same file (a nested call runs inline). Throws
137
+ * `CRUXY_E_STORE_LOCKED` if another live process holds it past the deadline;
138
+ * everything `fn` throws propagates, after the lock is released.
139
+ */
140
+ export function withStoreLock(file, fn) {
141
+ const key = path.resolve(file);
142
+ const nested = held.get(key);
143
+ if (nested) {
144
+ nested.depth++;
145
+ try {
146
+ return fn();
147
+ }
148
+ finally {
149
+ nested.depth--;
150
+ }
151
+ }
152
+ const lock = storeLockPath(key);
153
+ mkdirSync(path.dirname(key), { recursive: true });
154
+ for (let attempt = 0;; attempt++) {
155
+ const ino = acquire(lock, key);
156
+ held.set(key, { ino, depth: 1 });
157
+ try {
158
+ return fn();
159
+ }
160
+ catch (err) {
161
+ if (err instanceof StoreLockLostError &&
162
+ attempt < MAX_LOST_LOCK_RETRIES) {
163
+ continue; // nothing was committed; take the lock again and redo from a fresh load
164
+ }
165
+ throw err;
166
+ }
167
+ finally {
168
+ held.delete(key);
169
+ release(lock, ino);
170
+ }
171
+ }
172
+ }
173
+ /**
174
+ * For a store's write path: verify, right before the rename that commits the
175
+ * document, that the lock this process took for `file` is still the one on
176
+ * disk. No-op when the caller did not lock (a direct `saveScope` in a test,
177
+ * or `clear`'s deliberate recovery path).
178
+ */
179
+ export function assertStoreLockHeld(file) {
180
+ const key = path.resolve(file);
181
+ const h = held.get(key);
182
+ if (!h)
183
+ return;
184
+ let ino;
185
+ try {
186
+ ino = statSync(storeLockPath(key), { bigint: true }).ino;
187
+ }
188
+ catch {
189
+ throw new StoreLockLostError(key);
190
+ }
191
+ if (ino !== h.ino)
192
+ throw new StoreLockLostError(key);
193
+ }
194
+ // ── internals ─────────────────────────────────────────────────────────────────
195
+ function acquire(lock, file) {
196
+ const since = performance.now() - (lastReleased.get(lock) ?? -Infinity);
197
+ if (since < SPIN_MS * 2)
198
+ sleep(SPIN_MS);
199
+ const started = performance.now();
200
+ let deadline = started + STORE_LOCK_WAIT_MS;
201
+ let holder = null;
202
+ /** The last non-EEXIST create error, thrown as itself if nobody was ever
203
+ * seen holding the lock — a real permission problem, not contention. */
204
+ let lastDenied = null;
205
+ for (;;) {
206
+ try {
207
+ writeFileSync(lock, JSON.stringify(selfStamp()), {
208
+ flag: "wx",
209
+ mode: 0o600,
210
+ });
211
+ return statSync(lock, { bigint: true }).ino;
212
+ }
213
+ catch (err) {
214
+ if (!isContention(err))
215
+ throw err;
216
+ if (err.code !== "EEXIST")
217
+ lastDenied = err;
218
+ }
219
+ // "gone": released (or delete-pending) between our open and our read;
220
+ // retry the create, but through the deadline check below — a permanent
221
+ // EPERM must not spin forever.
222
+ const read = readStamp(lock);
223
+ if (read !== "gone" && read !== "unreadable") {
224
+ const { stamp, raw } = read;
225
+ // `self` here is our own pid and token with no entry in `held`: a lock
226
+ // this process created and lost track of (it cannot be a live holder —
227
+ // we are single-threaded and not inside `withStoreLock` for this file).
228
+ // Treated as stale, like any other dead stamp.
229
+ let verdict = verdicts.get(raw);
230
+ if (verdict === undefined) {
231
+ // The OS lookup behind `describeOwner` is not waiting time: it is one
232
+ // `ps` on darwin, and a PowerShell call on win32 that can itself
233
+ // outlast the whole budget. Push the deadline out by what it took.
234
+ const t0 = performance.now();
235
+ verdict = describeOwner(stamp);
236
+ deadline += performance.now() - t0;
237
+ verdicts.set(raw, verdict);
238
+ }
239
+ else if (verdict === "live" && !pidAlive(stamp.pid)) {
240
+ verdict = "stale";
241
+ verdicts.set(raw, verdict);
242
+ }
243
+ if (verdict !== "live") {
244
+ breakLock(lock);
245
+ continue;
246
+ }
247
+ holder = stamp;
248
+ }
249
+ const now = performance.now();
250
+ if (now >= deadline) {
251
+ if (holder === null && lastDenied !== null)
252
+ throw lastDenied;
253
+ throw storeLocked(file, lock, holder);
254
+ }
255
+ if (now - started >= BUSY_POLL_MS)
256
+ sleep(SPIN_MS * (0.5 + Math.random()));
257
+ }
258
+ }
259
+ /** How long {@link release} keeps retrying an unlink that win32 refuses
260
+ * while a contender holds the file open. A lock that a live holder failed to
261
+ * remove is refused by every other process for as long as this one runs, so
262
+ * the retry is worth far more than it costs. */
263
+ const RELEASE_RETRY_MS = 100;
264
+ function release(lock, ino) {
265
+ const until = performance.now() + RELEASE_RETRY_MS;
266
+ for (;;) {
267
+ try {
268
+ // Only remove the lock if it is still ours — never a successor's.
269
+ if (statSync(lock, { bigint: true }).ino === ino)
270
+ unlinkSync(lock);
271
+ break;
272
+ }
273
+ catch (err) {
274
+ // ENOENT: already gone (broken as stale by a contender, or never
275
+ // landed). A contention code: a reader has it open (win32); retry.
276
+ if (!isContention(err) || performance.now() >= until)
277
+ break;
278
+ sleep(SPIN_MS);
279
+ }
280
+ }
281
+ lastReleased.set(lock, performance.now());
282
+ }
283
+ function breakLock(lock) {
284
+ try {
285
+ unlinkSync(lock);
286
+ }
287
+ catch {
288
+ // Someone else broke it first; the next `wx` attempt decides.
289
+ }
290
+ }
291
+ function readStamp(lock) {
292
+ let raw;
293
+ try {
294
+ raw = readFileSync(lock, "utf8");
295
+ }
296
+ catch (err) {
297
+ // ENOENT: released before we read. A contention code: delete-pending on
298
+ // win32, which is the same thing a few microseconds earlier.
299
+ const code = err.code;
300
+ return code === "ENOENT" || isContention(err) ? "gone" : "unreadable";
301
+ }
302
+ try {
303
+ const parsed = JSON.parse(raw);
304
+ if (typeof parsed.pid !== "number" ||
305
+ typeof parsed.token !== "string" ||
306
+ typeof parsed.startedAt !== "string") {
307
+ return "unreadable";
308
+ }
309
+ return {
310
+ stamp: {
311
+ pid: parsed.pid,
312
+ token: parsed.token,
313
+ startedAt: parsed.startedAt,
314
+ },
315
+ raw,
316
+ };
317
+ }
318
+ catch {
319
+ return "unreadable";
320
+ }
321
+ }
322
+ function sleep(ms) {
323
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
324
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.11.4",
3
+ "version": "1.11.6",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {