@davesheffer/hunch 1.39.2 → 1.39.3

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.
Files changed (46) hide show
  1. package/dist/cli/index.js +97 -50
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/core/correction.js +12 -7
  5. package/dist/core/docanchors.js +16 -2
  6. package/dist/core/glob.d.ts +19 -0
  7. package/dist/core/glob.js +37 -0
  8. package/dist/core/paths.d.ts +27 -5
  9. package/dist/core/paths.js +51 -6
  10. package/dist/core/procstart.d.ts +94 -0
  11. package/dist/core/procstart.js +244 -0
  12. package/dist/core/served.d.ts +6 -3
  13. package/dist/core/served.js +10 -6
  14. package/dist/core/taskReport.d.ts +2 -0
  15. package/dist/core/taskReport.js +46 -11
  16. package/dist/core/taskReportEvidence.js +17 -1
  17. package/dist/core/taskReportHook.js +14 -2
  18. package/dist/core/workspace.d.ts +9 -0
  19. package/dist/core/workspace.js +15 -0
  20. package/dist/extractors/git.d.ts +5 -0
  21. package/dist/extractors/git.js +87 -10
  22. package/dist/extractors/indexer.d.ts +10 -0
  23. package/dist/extractors/indexer.js +17 -1
  24. package/dist/extractors/k8sManifest.js +104 -16
  25. package/dist/integrations/hookmatch.d.ts +25 -0
  26. package/dist/integrations/hookmatch.js +58 -0
  27. package/dist/integrations/hooks.d.ts +38 -7
  28. package/dist/integrations/hooks.js +250 -24
  29. package/dist/integrations/providers.js +2 -17
  30. package/dist/integrations/scaffold.d.ts +1 -1
  31. package/dist/integrations/scaffold.js +24 -21
  32. package/dist/integrations/workspaceLedger.d.ts +10 -0
  33. package/dist/integrations/workspaceLedger.js +9 -1
  34. package/dist/mcp/server.d.ts +10 -0
  35. package/dist/mcp/server.js +21 -13
  36. package/dist/serve/writelock.d.ts +21 -1
  37. package/dist/serve/writelock.js +157 -16
  38. package/dist/store/hunchStore.js +41 -11
  39. package/dist/store/jsonStore.d.ts +6 -4
  40. package/dist/store/jsonStore.js +225 -32
  41. package/dist/store/merge.d.ts +7 -4
  42. package/dist/store/merge.js +65 -5
  43. package/dist/store/privateMigrate.js +10 -0
  44. package/dist/wiki/adopt.js +13 -2
  45. package/package.json +1 -1
  46. package/server.json +2 -2
@@ -7,18 +7,28 @@
7
7
  * processes can both complete the read before either writes. An in-process mutex
8
8
  * does not span processes, so the lock is a file created with `open(path, "wx")` —
9
9
  * atomic on POSIX and Windows. A holder that dies leaves the file behind; a lock is
10
- * stealable once its owner is provably gone (pid dead on the same host) or older
11
- * than any plausible write.
10
+ * stealable once its owner is provably gone (pid dead — or RECYCLED — on the same
11
+ * host) or older than any plausible write. "Recycled" is decided by comparing the
12
+ * owner's recorded process-instance token with the current holder's, by string
13
+ * equality and never by clock arithmetic (see core/procstart.ts). Removing a stale
14
+ * lock is itself serialized behind an exclusive `.reclaim` claim directory, because
15
+ * judging and removing are two steps and two contenders can otherwise both "win"
16
+ * (see `takeOver`).
12
17
  */
13
18
  import { hostname } from "node:os";
14
19
  import { randomBytes } from "node:crypto";
15
- import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
20
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, rmdirSync, statSync, writeSync } from "node:fs";
16
21
  import { join } from "node:path";
22
+ import { heldLockNonces, judgeSameHostOwner, selfStartToken, startTokenOf } from "../core/procstart.js";
17
23
  /** Longest a write may hold the lock before another process may steal it: a put plus a
18
24
  * git commit and push — seconds, not minutes. */
19
25
  export const STALE_AFTER_MS = 60_000;
20
26
  /** Longest a writer waits before giving up and telling the caller. */
21
27
  export const ACQUIRE_TIMEOUT_MS = 10_000;
28
+ /** Longest a takeover claim may sit before another contender may clear it. The
29
+ * claimed section is a few syscalls, so anything this old means its claimer
30
+ * crashed inside it. */
31
+ export const CLAIM_STALE_MS = 10_000;
22
32
  const POLL_MS = 25;
23
33
  export class WriteLockTimeout extends Error {
24
34
  path;
@@ -42,7 +52,13 @@ function readOwner(path) {
42
52
  const parsed = JSON.parse(readFileSync(path, "utf8"));
43
53
  if (typeof parsed.pid !== "number" || typeof parsed.nonce !== "string")
44
54
  return undefined;
45
- return { pid: parsed.pid, host: typeof parsed.host === "string" ? parsed.host : "", nonce: parsed.nonce, at: typeof parsed.at === "string" ? parsed.at : "" };
55
+ return {
56
+ pid: parsed.pid,
57
+ host: typeof parsed.host === "string" ? parsed.host : "",
58
+ nonce: parsed.nonce,
59
+ at: typeof parsed.at === "string" ? parsed.at : "",
60
+ ...(typeof parsed.start === "string" ? { start: parsed.start } : {}),
61
+ };
46
62
  }
47
63
  catch {
48
64
  return undefined; // released between our open and this read, or truncated by a crash — age decides
@@ -57,7 +73,7 @@ function pidAlive(pid) {
57
73
  return error.code === "EPERM"; // exists, another user — alive for our purposes
58
74
  }
59
75
  }
60
- function stealable(path, owner, now) {
76
+ function stealable(path, owner, now, token) {
61
77
  let ageMs;
62
78
  try {
63
79
  ageMs = now - statSync(path).mtimeMs;
@@ -71,15 +87,124 @@ function stealable(path, owner, now) {
71
87
  // their record and ledger updates. The age fallback is only safe when the
72
88
  // owner is from another host (whose PID we cannot probe) or its metadata is
73
89
  // unreadable.
74
- if (owner && owner.host === hostname())
75
- return !pidAlive(owner.pid);
90
+ //
91
+ // The one exception is a PID that is alive but is no longer the OWNER: PIDs
92
+ // are recycled, and a container restart routinely gives the new writer the
93
+ // dead one's exact pid and hostname, so the orphaned lock names the contender
94
+ // itself and is never taken over again (issue #287). The owner's recorded
95
+ // process-instance token settles it by plain string equality — no clock is
96
+ // read, because the lock's mtime and a start time come from different clocks
97
+ // and any skew between them would "prove" a live owner recycled and steal its
98
+ // lock. Anything the token cannot settle stays "not proven".
99
+ //
100
+ // The whole table lives in core/procstart.ts so this lock and jsonStore's
101
+ // `.rmw-lock` can never drift apart; "age" means it proved nothing and the
102
+ // fallback below governs, exactly as for a foreign host.
103
+ if (owner && owner.host === hostname()) {
104
+ const verdict = judgeSameHostOwner(owner, {
105
+ alive: pidAlive,
106
+ self: token.self,
107
+ tokenOf: token.of,
108
+ platform: token.platform ?? process.platform,
109
+ selfPid: process.pid,
110
+ heldNonces: heldLockNonces,
111
+ });
112
+ if (verdict !== "age")
113
+ return verdict === "stale";
114
+ }
76
115
  return ageMs > STALE_AFTER_MS;
77
116
  }
78
117
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
79
- /** Hold the partition's lock across `fn` (sync or async); released even if it throws. */
118
+ /** The claim a contender must hold to REMOVE a stale lock. A sibling directory,
119
+ * created with the same exclusive `mkdir` the commit lock uses
120
+ * (src/extractors/git.ts `acquireCommitLock`) — a directory because an empty one
121
+ * is invisible to git and can never be staged. */
122
+ function claimPath(lockPath) {
123
+ return `${lockPath}.reclaim`;
124
+ }
125
+ /**
126
+ * Remove a stale lock under an exclusive claim, and report whether the path may
127
+ * now be free. "judge stale → rm → create" is NOT atomic: two contenders can
128
+ * both judge the same corpse stale, the first removes it and creates its own,
129
+ * and the second then removes that FRESH lock and creates a second holder
130
+ * (issue #287). Serializing the removal behind one claim closes it.
131
+ *
132
+ * INVARIANT: a stale lock's path can only be vacated by a claim holder (its
133
+ * owner is dead and will never release), and only one contender holds the claim
134
+ * at a time — so between the re-judge below and the `rm` nothing can replace the
135
+ * lock. A contender that judged stale a moment earlier re-judges under the
136
+ * claim, sees the winner's fresh live lock, and removes nothing.
137
+ *
138
+ * Residuals accepted:
139
+ * (i) an AGE-judged lock (foreign host, unprobeable owner, or unreadable owner
140
+ * metadata) whose owner is actually alive and releases inside the
141
+ * re-judge→rm window — the same class as the already-accepted "stolen after
142
+ * STALE_AFTER_MS";
143
+ * (ii) two contenders racing to clear a STRANDED claim. A claim's staleness is
144
+ * judged by ITS MTIME against OUR clock — the one clock comparison left in
145
+ * the takeover path — and there is no claim-nonce file to fall back on. So
146
+ * with filesystem/host clock skew beyond CLAIM_STALE_MS every LIVE claim
147
+ * looks stranded, and with three or more simultaneous contenders exclusion
148
+ * degrades to the pre-fix race. Without such skew it needs a claim section
149
+ * that is stranded plus simultaneous removers — and "stranded" is NOT only a
150
+ * crash: a STALL longer than CLAIM_STALE_MS between the re-judge and the
151
+ * `rm` (SIGSTOP, a VM pause or live migration, a laptop sleep) does it too.
152
+ * One other contender is then enough: it clears the "stranded" claim, takes
153
+ * over and publishes a LIVE lock, and the resumed claimer's `rm` removes
154
+ * that live lock; the unconditional `rmdirSync(claim)` in the `finally` can
155
+ * likewise remove a SUCCESSOR's claim. Same probability class as the age
156
+ * steal we already accept — a process stalled past the stale window while
157
+ * another writes — which is why it is accepted here too;
158
+ * (iii) a stranded claim blocks any takeover for up to CLAIM_STALE_MS —
159
+ * bounded, needing the same crash OR over-CLAIM_STALE_MS stall; a filesystem
160
+ * clock AHEAD of this host extends that block by the amount of the skew;
161
+ * (iv) MIXED VERSIONS: a pre-fix `hunch` ignores `.reclaim` entirely, so the
162
+ * takeover race persists until every writer on the store is upgraded.
163
+ */
164
+ function takeOver(path, token) {
165
+ const claim = claimPath(path);
166
+ try {
167
+ mkdirSync(claim);
168
+ }
169
+ catch (error) {
170
+ if (error.code === "EEXIST") {
171
+ // Someone else is mid-takeover — or crashed inside one. Clear only a
172
+ // provably stranded claim; either way, do not take over this round (the
173
+ // caller falls through to its deadline check and sleep, never a hot spin).
174
+ try {
175
+ if (Date.now() - statSync(claim).mtimeMs > CLAIM_STALE_MS)
176
+ rmdirSync(claim);
177
+ }
178
+ catch { /* vanished, or another contender cleared it first */ }
179
+ }
180
+ return false;
181
+ }
182
+ try {
183
+ // RE-JUDGE from scratch under the claim: the lock we judged stale may already
184
+ // have been replaced by the winner's live one.
185
+ const current = readOwner(path);
186
+ if (!stealable(path, current, Date.now(), token))
187
+ return false;
188
+ rmSync(path, { force: true });
189
+ return true;
190
+ }
191
+ finally {
192
+ try {
193
+ rmdirSync(claim);
194
+ }
195
+ catch { /* best effort; a stranded claim is cleared by age */ }
196
+ }
197
+ }
198
+ /** Hold the partition's lock across `fn` (sync or async); released even if it throws.
199
+ *
200
+ * `opts.startToken` is an INTERNAL TEST SEAM, not API: it lets the test suite
201
+ * drive the process-identity rows of the decision table on a platform that has
202
+ * no such identity. Callers outside this repo must leave it unset — the real
203
+ * probe (`selfStartToken`/`startTokenOf`) is the only sound one. */
80
204
  export async function withWriteLock(hunchDir, fn, opts = {}) {
81
205
  const path = writeLockPath(hunchDir);
82
206
  const timeoutMs = opts.timeoutMs ?? ACQUIRE_TIMEOUT_MS;
207
+ const token = opts.startToken ?? { self: selfStartToken(), of: startTokenOf };
83
208
  const nonce = randomBytes(8).toString("hex");
84
209
  const started = Date.now();
85
210
  let fd;
@@ -87,22 +212,31 @@ export async function withWriteLock(hunchDir, fn, opts = {}) {
87
212
  for (;;) {
88
213
  try {
89
214
  fd = openSync(path, "wx");
215
+ // Synchronously adjacent to the successful create, before any await: from
216
+ // here on this nonce is one WE hold, so our own later calls do not mistake
217
+ // it for a predecessor's (and adding it before the open would make a mere
218
+ // waiter look like a holder).
219
+ heldLockNonces.add(nonce);
90
220
  break;
91
221
  }
92
222
  catch (error) {
93
223
  if (error.code !== "EEXIST")
94
224
  throw error;
95
225
  lastOwner = readOwner(path);
96
- if (stealable(path, lastOwner, Date.now())) {
97
- rmSync(path, { force: true });
226
+ if (stealable(path, lastOwner, Date.now(), token) && takeOver(path, token))
98
227
  continue;
99
- }
100
228
  if (Date.now() - started >= timeoutMs)
101
229
  throw new WriteLockTimeout(path, lastOwner, Date.now() - started);
102
230
  await sleep(POLL_MS);
103
231
  }
104
232
  }
105
- const owner = { pid: process.pid, host: hostname(), nonce, at: new Date().toISOString() };
233
+ const owner = {
234
+ pid: process.pid,
235
+ host: hostname(),
236
+ nonce,
237
+ at: new Date().toISOString(),
238
+ ...(token.self !== null ? { start: token.self } : {}),
239
+ };
106
240
  try {
107
241
  writeSync(fd, JSON.stringify(owner));
108
242
  }
@@ -113,10 +247,17 @@ export async function withWriteLock(hunchDir, fn, opts = {}) {
113
247
  }
114
248
  finally {
115
249
  // Only remove a lock that is still ours: if we overran STALE_AFTER_MS and were stolen
116
- // from, the file now protects another writer.
117
- const current = readOwner(path);
118
- if (!current || current.nonce === nonce)
119
- rmSync(path, { force: true });
250
+ // from, the file now protects another writer. The nonce is dropped in a
251
+ // `finally` of its own, so a throw from the conditional remove can never
252
+ // leave us vouching for a lock we no longer hold.
253
+ try {
254
+ const current = readOwner(path);
255
+ if (!current || current.nonce === nonce)
256
+ rmSync(path, { force: true });
257
+ }
258
+ finally {
259
+ heldLockNonces.delete(nonce);
260
+ }
120
261
  }
121
262
  }
122
263
  //# sourceMappingURL=writelock.js.map
@@ -12,14 +12,14 @@
12
12
  */
13
13
  import { resolve, join, dirname, isAbsolute, relative } from "node:path";
14
14
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
15
- import { toPosixTarget, hunchPathsForDir } from "../core/paths.js";
15
+ import { toPosixTarget, repoRelativeTarget, hunchPathsForDir } from "../core/paths.js";
16
16
  import { ENTITY_KINDS } from "../core/types.js";
17
17
  import { openDb, withTx } from "./db.js";
18
18
  import { RESET_SQL, embedHash } from "./schema.js";
19
19
  import { selectEmbedder } from "./embedder.js";
20
20
  import { JsonStore } from "./jsonStore.js";
21
21
  import { gitCommonDir, gitWorktreeRoot, isolatedHeadSha, sameGitPublication, scopedLastChangeDates, } from "../extractors/git.js";
22
- import { pathMatchesGlob, pathsRelated } from "../core/glob.js";
22
+ import { pathMatchesGlob, pathsRelated, isIndexedPath, matchSymbolsTiered } from "../core/glob.js";
23
23
  import { cochangeFor } from "../core/cochange.js";
24
24
  import { withServedDatabase } from "../core/served.js";
25
25
  import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectTaskSlots } from "../core/taskRanking.js";
@@ -1184,7 +1184,9 @@ export class HunchStore {
1184
1184
  * instant — "what did we believe as of commit X?". Omit `asOf` for the full,
1185
1185
  * history-inclusive view (backward-compatible default). */
1186
1186
  why(target, opts = {}) {
1187
- target = toPosixTarget(target);
1187
+ // An absolute target (an agent's edit-payload path, verbatim) never matched
1188
+ // the repo-relative stored file paths below (issue #299).
1189
+ target = repoRelativeTarget(target, this.paths.root);
1188
1190
  const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
1189
1191
  const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
1190
1192
  const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
@@ -1194,11 +1196,24 @@ export class HunchStore {
1194
1196
  // pathsRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
1195
1197
  // so an unanchored suffix pulled unrelated files' records into why()/the
1196
1198
  // pre-edit grounding block (issue #32). Segment-anchored matching only.
1197
- const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || pathsRelated(s.file, target));
1199
+ //
1200
+ // A target that IS a path Hunch's index already knows about (README.md,
1201
+ // vscode-extension/index.ts, ...) names exactly one file — suffix-matching
1202
+ // it too pulls in every OTHER file that merely shares a basename (issue
1203
+ // #299: root index.ts delivered vscode-extension/index.ts's rules and vice
1204
+ // versa). "Known to the index" is answered from already-loaded graph data
1205
+ // (a symbol's exact file, or an indexed component's path glob — so a
1206
+ // symbol-less real file still counts as real), never the filesystem: a
1207
+ // deleted-but-still-indexed path must answer the same either way. Suffix
1208
+ // matching stays reserved for a target that is not itself a real indexed
1209
+ // path, e.g. a short/partial reference like "x/scenario.ts".
1210
+ const indexed = isIndexedPath(target, symbols.map((s) => s.file), components.map((c) => c.paths));
1211
+ const suffixMatch = (file) => !indexed && pathsRelated(file, target);
1212
+ const matchedSymbols = matchSymbolsTiered(target, symbols, indexed);
1198
1213
  const symIds = new Set(matchedSymbols.map((s) => s.id));
1199
1214
  const fileSet = new Set(matchedSymbols.map((s) => s.file));
1200
1215
  const isPath = target.includes("/") || target.includes(".");
1201
- const fileMatch = (files) => files.some((f) => f === target || (isPath && pathsRelated(f, target)) || fileSet.has(f));
1216
+ const fileMatch = (files) => files.some((f) => f === target || (isPath && suffixMatch(f)) || fileSet.has(f));
1202
1217
  return {
1203
1218
  target,
1204
1219
  decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
@@ -1426,6 +1441,10 @@ export class HunchStore {
1426
1441
  * longer enforced. Pass `{ asOf }` to instead return the invariants in force at
1427
1442
  * that instant (time-travel: "what must I not have broken as of commit X?"). */
1428
1443
  checkConstraints(scope, opts = {}) {
1444
+ // Constraint scopes are always repo-relative; an absolute target (an
1445
+ // agent's edit-payload path, verbatim) matched nothing even when a
1446
+ // blocking rule plainly applied (issue #296).
1447
+ scope = repoRelativeTarget(scope, this.paths.root);
1429
1448
  const all = this.recs("constraints");
1430
1449
  const asOf = opts.asOf;
1431
1450
  return all
@@ -1439,21 +1458,29 @@ export class HunchStore {
1439
1458
  * glob, and the queried scope may be either too. Advisory only — findings never
1440
1459
  * enter any block path. Sorted worst-first, then id for stable output. */
1441
1460
  liveFindingsFor(scope) {
1442
- const t = toPosixTarget(scope);
1461
+ // Same absolute-target and same-basename-leak gaps as why() (issue #299): an
1462
+ // un-rewritten absolute scope never matched the repo-relative `affected_files`
1463
+ // below, and an un-guarded suffix fallback could pull in an unrelated file
1464
+ // that merely shares a basename. Normalize once and reuse the same "is this
1465
+ // path known to the index" guard.
1466
+ const t = repoRelativeTarget(scope, this.paths.root);
1467
+ const indexed = isIndexedPath(t, this.recs("symbols").map((s) => s.file), this.recs("components").map((c) => c.paths));
1443
1468
  const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
1444
1469
  return this.recs("findings")
1445
1470
  .filter(live)
1446
- .filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || pathsRelated(toPosixTarget(af), t))
1447
- || f.affected_symbols.some((s) => s === scope))
1471
+ .filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || (!indexed && pathsRelated(toPosixTarget(af), t)))
1472
+ || f.affected_symbols.some((s) => s === t))
1448
1473
  .sort((a, b) => (SEV_FINDING[b.severity] ?? 0) - (SEV_FINDING[a.severity] ?? 0) || a.id.localeCompare(b.id));
1449
1474
  }
1450
1475
  /** Finished agent tasks that touched a file or glob (rule-checked changes and
1451
1476
  * denied edits), newest first. Graph memory, so it spans machines and survives
1452
1477
  * the local ledger's retention window. */
1453
1478
  tasksFor(scope, limit = 8) {
1454
- const t = toPosixTarget(scope);
1479
+ // Same normalization + indexed-suffix guard as liveFindingsFor/why() (issue #299).
1480
+ const t = repoRelativeTarget(scope, this.paths.root);
1481
+ const indexed = isIndexedPath(t, this.recs("symbols").map((s) => s.file), this.recs("components").map((c) => c.paths));
1455
1482
  return this.recs("tasks")
1456
- .filter((r) => r.files.some((f) => pathMatchesGlob(t, f) || pathMatchesGlob(f, t) || pathsRelated(toPosixTarget(f), t)))
1483
+ .filter((r) => r.files.some((f) => pathMatchesGlob(t, f) || pathMatchesGlob(f, t) || (!indexed && pathsRelated(toPosixTarget(f), t))))
1457
1484
  .sort((a, b) => b.finished_at.localeCompare(a.finished_at) || a.id.localeCompare(b.id))
1458
1485
  .slice(0, Math.max(1, limit));
1459
1486
  }
@@ -2048,7 +2075,10 @@ export class HunchStore {
2048
2075
  * a task on `target`, ordered by what matters most — invariants first, then the
2049
2076
  * why, then blast radius and bug history — trimmed to a rough token budget. */
2050
2077
  assembleContext(target, budget = 1500, opts = {}) {
2051
- target = toPosixTarget(target);
2078
+ // An absolute target (an agent's edit-payload path, verbatim) must be
2079
+ // repo-relative BEFORE it reaches why()/liveFindingsFor() below, or an
2080
+ // absolute path never matches the repo-relative stored data (issue #299).
2081
+ target = repoRelativeTarget(target, this.paths.root);
2052
2082
  const w = this.why(target, opts);
2053
2083
  const symIds = w.symbols.map((s) => s.id);
2054
2084
  const blast = new Map();
@@ -78,10 +78,12 @@ export declare class JsonStore {
78
78
  * The long-lived MCP server and CLI hooks write the same `.hunch/` concurrently;
79
79
  * two unsynchronized RMWs over index.json each read the same base array and the
80
80
  * second rename silently erases the first's record. `mkdirSync` is the atomic
81
- * acquire (EEXIST = held). A stale lock (killed process) is taken over by age;
82
- * against a live contender we wait briefly and then refuse the write. Proceeding
83
- * without the lock would reintroduce the record-loss race this mutex exists to
84
- * prevent. */
81
+ * acquire (EEXIST = held). A stale lock (killed process, or one whose pid was
82
+ * RECYCLED) is taken over behind an exclusive `.reclaim` claim — see
83
+ * `takeOverRmwLock`, because judging and removing are two steps and two
84
+ * contenders could otherwise both "win". Against a live contender we wait
85
+ * briefly and then refuse the write. Proceeding without the lock would
86
+ * reintroduce the record-loss race this mutex exists to prevent. */
85
87
  private withSingleFileLock;
86
88
  /** Write a single record (validated) to its JSON file / into the index array. */
87
89
  put<K extends EntityKind>(kind: K, record: EntityFor[K]): EntityFor[K];