@davesheffer/hunch 1.45.1 → 1.46.0

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 (64) hide show
  1. package/dist/cli/index.js +165 -33
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/serve.d.ts +2 -0
  5. package/dist/cli/serve.js +2 -1
  6. package/dist/cli/verify.d.ts +28 -0
  7. package/dist/cli/verify.js +265 -0
  8. package/dist/constitution/experiment.d.ts +1 -1
  9. package/dist/core/agenthook.d.ts +1 -0
  10. package/dist/core/agenthook.js +82 -26
  11. package/dist/core/boundedSpawn.d.ts +57 -0
  12. package/dist/core/boundedSpawn.js +321 -0
  13. package/dist/core/config.d.ts +6 -0
  14. package/dist/core/config.js +1 -0
  15. package/dist/core/delivery.d.ts +10 -0
  16. package/dist/core/delivery.js +117 -9
  17. package/dist/core/migrate.d.ts +10 -1
  18. package/dist/core/migrate.js +17 -6
  19. package/dist/core/pipeline.d.ts +9 -2
  20. package/dist/core/pipeline.js +13 -3
  21. package/dist/core/procstart.d.ts +17 -3
  22. package/dist/core/procstart.js +26 -5
  23. package/dist/core/projectDna.js +11 -1
  24. package/dist/core/relativeImports.js +6 -2
  25. package/dist/core/taskSelection.d.ts +1 -0
  26. package/dist/core/taskSelection.js +132 -11
  27. package/dist/core/testSelection.d.ts +122 -0
  28. package/dist/core/testSelection.js +588 -0
  29. package/dist/core/whyRecord.d.ts +47 -0
  30. package/dist/core/whyRecord.js +105 -0
  31. package/dist/extractors/git.d.ts +32 -0
  32. package/dist/extractors/git.js +206 -94
  33. package/dist/extractors/indexer.js +42 -6
  34. package/dist/extractors/languages.d.ts +9 -0
  35. package/dist/extractors/languages.js +28 -0
  36. package/dist/extractors/parse.d.ts +20 -0
  37. package/dist/extractors/parse.js +81 -5
  38. package/dist/extractors/workspaces.js +3 -1
  39. package/dist/integrations/health.d.ts +6 -0
  40. package/dist/integrations/health.js +33 -12
  41. package/dist/integrations/hooks.d.ts +4 -2
  42. package/dist/integrations/hooks.js +12 -2
  43. package/dist/integrations/mergeDriver.js +5 -3
  44. package/dist/integrations/optout.d.ts +3 -0
  45. package/dist/integrations/optout.js +17 -0
  46. package/dist/integrations/providers.d.ts +6 -2
  47. package/dist/integrations/providers.js +19 -2
  48. package/dist/integrations/team.js +6 -4
  49. package/dist/integrations/uninstall.d.ts +17 -4
  50. package/dist/integrations/uninstall.js +35 -13
  51. package/dist/mcp/server.js +179 -30
  52. package/dist/serve/app.js +9 -1
  53. package/dist/serve/config.js +5 -0
  54. package/dist/serve/writelock.d.ts +6 -5
  55. package/dist/serve/writelock.js +26 -21
  56. package/dist/store/hunchStore.d.ts +12 -1
  57. package/dist/store/hunchStore.js +86 -4
  58. package/dist/store/jsonStore.d.ts +24 -4
  59. package/dist/store/jsonStore.js +75 -26
  60. package/dist/store/stateBinding.js +17 -4
  61. package/dist/synthesis/provider.js +26 -78
  62. package/dist/synthesis/synthesize.js +1 -1
  63. package/package.json +2 -2
  64. package/server.json +2 -2
@@ -13,7 +13,9 @@
13
13
  * equality and never by clock arithmetic (see core/procstart.ts). Where no token can
14
14
  * compare them (macOS, Windows, legacy records) a stale-looking lock whose live pid
15
15
  * belongs to a process CREATED after the lock's recorded `at` is taken over too: that
16
- * process cannot be the one that took it (issue #287). Removing a stale
16
+ * process cannot be the one that took it (issue #287). That creation-time check
17
+ * runs on macOS and Windows only; on linux `processStartedAt` returns null, so a
18
+ * tokenless live pid there is never taken over. Removing a stale
17
19
  * lock is itself serialized behind an exclusive `.reclaim` claim directory, because
18
20
  * judging and removing are two steps and two contenders can otherwise both "win"
19
21
  * (see `takeOver`).
@@ -22,7 +24,10 @@ import { hostname } from "node:os";
22
24
  import { randomBytes } from "node:crypto";
23
25
  import { closeSync, mkdirSync, openSync, readFileSync, rmSync, rmdirSync, statSync, writeSync } from "node:fs";
24
26
  import { join } from "node:path";
25
- import { heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
27
+ import { createdAfterLock, heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
28
+ /** Shared with jsonStore's `.rmw-lock` (core/procstart.ts); re-exported so this
29
+ * module's surface is unchanged. */
30
+ export { CREATED_AFTER_SLACK_MS } from "../core/procstart.js";
26
31
  /** Longest a write may hold the lock before another process may steal it: a put plus a
27
32
  * git commit and push — seconds, not minutes. */
28
33
  export const STALE_AFTER_MS = 60_000;
@@ -33,10 +38,6 @@ export const ACQUIRE_TIMEOUT_MS = 10_000;
33
38
  * crashed inside it. */
34
39
  export const CLAIM_STALE_MS = 10_000;
35
40
  const POLL_MS = 25;
36
- /** How much later than the lock's recorded `at` the pid's current process must
37
- * have been created before it is proven not to be the owner. A genuine owner
38
- * started BEFORE it took the lock; the slack only absorbs clock granularity. */
39
- export const CREATED_AFTER_SLACK_MS = 5_000;
40
41
  export class WriteLockTimeout extends Error {
41
42
  path;
42
43
  heldBy;
@@ -57,12 +58,19 @@ export function writeLockPath(hunchDir) {
57
58
  function readOwner(path) {
58
59
  try {
59
60
  const parsed = JSON.parse(readFileSync(path, "utf8"));
60
- if (typeof parsed.pid !== "number" || typeof parsed.nonce !== "string")
61
+ // A missing nonce is not unreadable metadata: the pid still names a
62
+ // same-host owner, so it gets the live-pid judgement `.rmw-lock` gives it,
63
+ // never the age fallback that would steal a live writer's lock (#491).
64
+ // The pid check mirrors `.rmw-lock`'s: `kill(0|-n, 0)` signals a process
65
+ // group and always "succeeds" (a live wedge forever), and a fractional or
66
+ // huge pid throws (stolen even when fresh) — so such a record is unreadable
67
+ // and age decides.
68
+ if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid < 1)
61
69
  return undefined;
62
70
  return {
63
71
  pid: parsed.pid,
64
72
  host: typeof parsed.host === "string" ? parsed.host : "",
65
- nonce: parsed.nonce,
73
+ ...(typeof parsed.nonce === "string" ? { nonce: parsed.nonce } : {}),
66
74
  at: typeof parsed.at === "string" ? parsed.at : "",
67
75
  ...(typeof parsed.start === "string" ? { start: parsed.start } : {}),
68
76
  };
@@ -104,9 +112,10 @@ function stealable(path, owner, now, token) {
104
112
  // and any skew between them would "prove" a live owner recycled and steal its
105
113
  // lock. Anything the token cannot settle stays "not proven".
106
114
  //
107
- // The whole table lives in core/procstart.ts so this lock and jsonStore's
108
- // `.rmw-lock` can never drift apart; "age" means it proved nothing and the
109
- // fallback below governs, exactly as for a foreign host.
115
+ // The whole table, and the creation-time check for an "unproven" pid, live in
116
+ // core/procstart.ts so this lock and jsonStore's `.rmw-lock` apply one rule;
117
+ // "age" means it proved nothing and the fallback below governs, exactly as
118
+ // for a foreign host.
110
119
  if (owner && owner.host === hostname()) {
111
120
  const verdict = judgeSameHostOwner(owner, {
112
121
  alive: pidAlive,
@@ -117,21 +126,12 @@ function stealable(path, owner, now, token) {
117
126
  heldNonces: heldLockNonces,
118
127
  });
119
128
  if (verdict === "unproven")
120
- return ageMs > STALE_AFTER_MS && createdAfterLock(owner, token);
129
+ return ageMs > STALE_AFTER_MS && createdAfterLock(owner, token.startedAt);
121
130
  if (verdict !== "age")
122
131
  return verdict === "stale";
123
132
  }
124
133
  return ageMs > STALE_AFTER_MS;
125
134
  }
126
- /** Is the process now holding the owner's pid provably newer than the lock?
127
- * Unreadable either way means not proven, and a live pid keeps its lock. */
128
- function createdAfterLock(owner, token) {
129
- const at = Date.parse(owner.at);
130
- if (!Number.isFinite(at))
131
- return false;
132
- const started = (token.startedAt ?? processStartedAt)(owner.pid);
133
- return started !== null && started > at + CREATED_AFTER_SLACK_MS;
134
- }
135
135
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
136
136
  /** The claim a contender must hold to REMOVE a stale lock. A sibling directory,
137
137
  * created with the same exclusive `mkdir` the commit lock uses
@@ -277,6 +277,11 @@ export async function withWriteLock(hunchDir, fn, opts = {}) {
277
277
  // from, the file now protects another writer. The nonce is dropped in a
278
278
  // `finally` of its own, so a throw from the conditional remove can never
279
279
  // leave us vouching for a lock we no longer hold.
280
+ //
281
+ // DIFFERS from `.rmw-lock` on release: a readable record WITHOUT a nonce is
282
+ // left in place here (it is not ours — a legacy or foreign writer replaced
283
+ // our file), where `.rmw-lock` removes it. Only an unreadable record is
284
+ // removed unconditionally.
280
285
  try {
281
286
  const current = readOwner(path);
282
287
  if (!current || current.nonce === nonce)
@@ -4,6 +4,7 @@ import { type DB } from "./db.js";
4
4
  import { type Embedder } from "./embedder.js";
5
5
  import { JsonStore } from "./jsonStore.js";
6
6
  import { type RankingContext, type RankingQuery, type RankingWeights, type SlotOptions, type TaskSelection } from "../core/taskRanking.js";
7
+ import { type WhyById } from "../core/whyRecord.js";
7
8
  import { type VetoTier } from "../core/strictgate.js";
8
9
  import { type DiffAnalysis, type DiffStatus } from "../extractors/diff.js";
9
10
  import type { CheckReport, CausalWhy, ImpactReport } from "../core/checkreport.js";
@@ -43,6 +44,8 @@ export interface WhyResult {
43
44
  constraints: Constraint[];
44
45
  symbols: Symbol[];
45
46
  components: Component[];
47
+ /** Set when the target is a record id (`dec_…`, `con_…`, …; issue #474). */
48
+ byId?: WhyById;
46
49
  }
47
50
  export interface FragileNode {
48
51
  id: string;
@@ -391,9 +394,17 @@ export declare class HunchStore {
391
394
  asOf?: string;
392
395
  canRead?: (record: unknown) => boolean;
393
396
  }): WhyResult;
397
+ /** Look a record up by id across the record kinds why() explains (public ∪ private
398
+ * via recs(), narrowed by `canRead`). A decision carries its supersession chain.
399
+ * With `asOf`, a record outside its valid window then is "no record", and only
400
+ * chain links/neighbours already in effect then are shown. Status is the STORED
401
+ * status, exactly as the path/symbol view prints it.
402
+ * Neighbour ids the caller may not read are never printed. */
403
+ private whyRecord;
394
404
  /** Transitive blast radius: every symbol/component that (in)directly depends on
395
405
  * `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
396
- * walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
406
+ * walk edges BACKWARD (edges.to = current) following call/dep/import/contains,
407
+ * plus `references` — the only edge YAML/Helm/Kustomize emit (issue #486). */
397
408
  getDependents(id: string, maxDepth?: number): Array<{
398
409
  id: string;
399
410
  depth: number;
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * - reindex(): JSON -> SQLite (rebuild the derived index + FTS)
6
6
  * - search(): FTS5 ranked query (hunch_query)
7
- * - why(): decisions/bugs/constraints explaining a path/symbol
7
+ * - why(): decisions/bugs/constraints explaining a path/symbol, or one record by id
8
8
  * - getDependents(): recursive-CTE blast radius over the call/dep graph
9
9
  * - checkConstraints(): constraints whose scope matches a glob/path
10
10
  * - bugLineage(): bugs matching a symptom/symbol + their lineage
@@ -26,6 +26,7 @@ import { normalizePath, rankTaskRecords, recordIdsOf, selectLatestTasks, selectT
26
26
  import { resolveTaskRankingMode } from "../core/taskRankingMode.js";
27
27
  import { currentForTopic, isInForce } from "../core/topics.js";
28
28
  import { edgeId } from "../core/ids.js";
29
+ import { WHY_GENERATED_ID, WHY_RECORD_ID } from "../core/whyRecord.js";
29
30
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
30
31
  import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
31
32
  import { analyzeDiff, diffContentGaps } from "../extractors/diff.js";
@@ -1343,6 +1344,16 @@ export class HunchStore {
1343
1344
  // An absolute target (an agent's edit-payload path, verbatim) never matched
1344
1345
  // the repo-relative stored file paths below (issue #299).
1345
1346
  target = repoRelativeTarget(target, this.paths.root);
1347
+ // A record id (every delivered brief line ends in `hunch_why("dec_…")`) resolves
1348
+ // to that record directly (issue #474). With `asOf`, a record not in force then
1349
+ // is "no record"; status stays as stored, like the path view (see whyRecord).
1350
+ const lookup = WHY_RECORD_ID.test(target) ? this.whyRecord(target, opts.canRead, opts.asOf) : undefined;
1351
+ if (lookup?.record)
1352
+ return { target, decisions: [], bugs: [], constraints: [], symbols: [], components: [], byId: lookup };
1353
+ // A miss is reported for a generated-shape id, or for any id on file that was not
1354
+ // in force at `asOf` (issue #493); anything else resolves as before.
1355
+ const byId = lookup && (WHY_GENERATED_ID.test(target) || (opts.asOf && this.whyRecord(target, opts.canRead).record))
1356
+ ? lookup : undefined;
1346
1357
  const decisions = this.recs("decisions").filter(opts.canRead ?? (() => true));
1347
1358
  const bugs = this.recs("bugs").filter(opts.canRead ?? (() => true));
1348
1359
  const constraints = this.recs("constraints").filter(opts.canRead ?? (() => true));
@@ -1379,11 +1390,66 @@ export class HunchStore {
1379
1390
  && inWindow(c.valid_from, c.valid_to, asOf)),
1380
1391
  symbols: matchedSymbols,
1381
1392
  components: components.filter((c) => c.paths.some((g) => pathMatchesGlob(target, g) || [...fileSet].some((f) => pathMatchesGlob(f, g)))),
1393
+ ...(byId ? { byId } : {}),
1382
1394
  };
1383
1395
  }
1396
+ /** Look a record up by id across the record kinds why() explains (public ∪ private
1397
+ * via recs(), narrowed by `canRead`). A decision carries its supersession chain.
1398
+ * With `asOf`, a record outside its valid window then is "no record", and only
1399
+ * chain links/neighbours already in effect then are shown. Status is the STORED
1400
+ * status, exactly as the path/symbol view prints it.
1401
+ * Neighbour ids the caller may not read are never printed. */
1402
+ whyRecord(id, canRead = () => true, asOf) {
1403
+ const find = (kind) => this.recs(kind).find((r) => r.id === id && canRead(r));
1404
+ // Decisions the caller may read that had taken effect by `asOf` (all when no asOf).
1405
+ const decs = new Map(this.recs("decisions").filter((x) => canRead(x) && inWindow(x.valid_from, null, asOf)).map((x) => [x.id, x]));
1406
+ const visible = (ref) => (ref && decs.has(ref) ? ref : null);
1407
+ const existed = (instant) => !asOf || instant <= asOf;
1408
+ let record = null;
1409
+ switch (id.slice(0, id.indexOf("_"))) {
1410
+ case "dec": {
1411
+ const d = find("decisions");
1412
+ if (!d || !inWindow(d.valid_from, d.valid_to, asOf))
1413
+ break;
1414
+ // Stored status, as the path view prints it; asOf only hides neighbours not yet in effect.
1415
+ const shown = { ...d, supersedes: visible(d.supersedes), superseded_by: visible(d.superseded_by) };
1416
+ record = { kind: "decision", record: shown, chain: supersessionChain(shown, decs) };
1417
+ break;
1418
+ }
1419
+ case "con": {
1420
+ const c = find("constraints");
1421
+ if (!c || !inWindow(c.valid_from, c.valid_to, asOf))
1422
+ break;
1423
+ record = { kind: "constraint", record: { ...c, source_decision: visible(c.source_decision) } };
1424
+ break;
1425
+ }
1426
+ case "bug": {
1427
+ const b = find("bugs");
1428
+ if (b)
1429
+ record = { kind: "bug", record: b };
1430
+ break;
1431
+ }
1432
+ case "fnd": {
1433
+ const f = find("findings");
1434
+ if (f && existed(f.observed_at))
1435
+ record = { kind: "finding", record: f };
1436
+ break;
1437
+ }
1438
+ // Not gated by asOf: the path view lists components regardless of asOf too.
1439
+ // Findings stay gated by observed_at (the path view does not list findings).
1440
+ case "cmp": {
1441
+ const c = find("components");
1442
+ if (c)
1443
+ record = { kind: "component", record: c };
1444
+ break;
1445
+ }
1446
+ }
1447
+ return { id, record, ...(asOf ? { asOf } : {}) };
1448
+ }
1384
1449
  /** Transitive blast radius: every symbol/component that (in)directly depends on
1385
1450
  * `id`, via a recursive CTE over the edges graph (hunch_get_dependents). We
1386
- * walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
1451
+ * walk edges BACKWARD (edges.to = current) following call/dep/import/contains,
1452
+ * plus `references` — the only edge YAML/Helm/Kustomize emit (issue #486). */
1387
1453
  getDependents(id, maxDepth = 6) {
1388
1454
  return this.db.prepare(
1389
1455
  /* sql */ `
@@ -1392,7 +1458,7 @@ export class HunchStore {
1392
1458
  UNION
1393
1459
  SELECT e."from", up.depth + 1
1394
1460
  FROM edges e JOIN up ON e."to" = up.node
1395
- WHERE e.type IN ('calls','depends_on','imports','contains','implements') AND up.depth < ?
1461
+ WHERE e.type IN ('calls','depends_on','imports','contains','implements','references') AND up.depth < ?
1396
1462
  )
1397
1463
  SELECT up.node AS id, MIN(up.depth) AS depth,
1398
1464
  COALESCE(s.name || ' @ ' || s.file, c.name, up.node) AS via
@@ -1434,7 +1500,7 @@ export class HunchStore {
1434
1500
  UNION
1435
1501
  SELECT e."from", up.depth + 1
1436
1502
  FROM edges e JOIN up ON e."to" = up.node
1437
- WHERE e.type IN ('calls','depends_on','imports','contains','implements') AND up.depth < ?
1503
+ WHERE e.type IN ('calls','depends_on','imports','contains','implements','references') AND up.depth < ?
1438
1504
  )
1439
1505
  SELECT s.file AS file, s.name AS via, MIN(up.depth) AS depth
1440
1506
  FROM up JOIN symbols s ON s.id = up.node
@@ -2334,6 +2400,22 @@ function inWindow(valid_from, valid_to, asOf) {
2334
2400
  return false;
2335
2401
  return true;
2336
2402
  }
2403
+ /** A decision's supersedes/superseded_by chain over `decs` (the readable, already
2404
+ * in-effect decisions), oldest first, cycle-guarded. `d` is the record as shown. */
2405
+ function supersessionChain(d, decs) {
2406
+ const seen = new Set([d.id]);
2407
+ const back = [];
2408
+ for (let cur = d.supersedes ? decs.get(d.supersedes) : undefined; cur && !seen.has(cur.id); cur = cur.supersedes ? decs.get(cur.supersedes) : undefined) {
2409
+ seen.add(cur.id);
2410
+ back.unshift(cur);
2411
+ }
2412
+ const fwd = [];
2413
+ for (let cur = d.superseded_by ? decs.get(d.superseded_by) : undefined; cur && !seen.has(cur.id); cur = cur.superseded_by ? decs.get(cur.superseded_by) : undefined) {
2414
+ seen.add(cur.id);
2415
+ fwd.push(cur);
2416
+ }
2417
+ return [...back, d, ...fwd].map((x) => ({ id: x.id, status: x.status, title: x.title }));
2418
+ }
2337
2419
  function round(n) {
2338
2420
  return Math.round(n * 100) / 100;
2339
2421
  }
@@ -6,8 +6,11 @@ export declare const MAX_JSON_RECORD_BYTES: number;
6
6
  export declare const MAX_JSON_INDEX_BYTES: number;
7
7
  export declare const MAX_JSON_MANIFEST_BYTES: number;
8
8
  export declare const MAX_JSON_DIRECTORY_ENTRIES_PER_KIND = 100000;
9
+ /** When the process now holding `pid` was created (wall-clock ms), or null. */
10
+ type StartedAtProbe = (pid: number) => number | null;
9
11
  export declare class JsonStore {
10
12
  private readonly paths;
13
+ private readonly lockOpts;
11
14
  private _warnedForward;
12
15
  /** Memoized validated records per kind. loadAll() is on every read-path method
13
16
  * (why/checkConstraints/fragility/assembleContext…), each of which previously
@@ -20,7 +23,13 @@ export declare class JsonStore {
20
23
  private readonly lexicalRoot;
21
24
  private readonly canonicalRoot;
22
25
  private readonly lexicalHunch;
23
- constructor(paths: HunchPaths);
26
+ /** `lockOpts.startedAt` is an INTERNAL TEST SEAM, not API: it replaces the OS
27
+ * creation-time probe of the `.rmw-lock` "unproven" rule so the suite can
28
+ * drive it on linux, where the real probe reports nothing. Callers outside
29
+ * this repo must leave it unset — `processStartedAt` is the only sound probe. */
30
+ constructor(paths: HunchPaths, lockOpts?: {
31
+ startedAt?: StartedAtProbe;
32
+ });
24
33
  private lstatOrMissing;
25
34
  private expectedCanonical;
26
35
  /** Require an ordinary, canonically-contained directory. Creation is one
@@ -117,11 +126,22 @@ export declare class JsonStore {
117
126
  * overlay. Returns the number of files removed. Invalidates the memoized load. */
118
127
  dropAll(kind: EntityKind): number;
119
128
  /** Persist a schema migration: rewrite every LOADABLE record in its current shape.
120
- * A record that still fails validation after migration is kept untouched (never
121
- * deleted) and counted as `skipped`, so migration can't lose data. The caller
122
- * bumps the manifest afterward. */
129
+ * A record that still fails validation after migration (or whose migration
130
+ * throws) is kept in its ORIGINAL shape (never deleted, never half-migrated) and
131
+ * counted as `skipped`, so migration can't lose data. The caller stamps the
132
+ * manifest at the returned `version`: SCHEMA_VERSION only when nothing was
133
+ * skipped, else the FROM version, so a manual fix plus a rerun re-applies the
134
+ * (idempotent) migrations to the skipped records instead of skipping them.
135
+ * A bad record of a kind NO pending migration touches can't be fixed by a
136
+ * rerun, so it is counted as `invalid` instead and doesn't hold the version. */
123
137
  persistMigration(): {
124
138
  migrated: number;
125
139
  skipped: number;
140
+ invalid: number;
141
+ version: number;
126
142
  };
143
+ /** Migrate + validate one raw record for persistMigration; a throwing migration
144
+ * step counts as a failure, so the record stays in its original shape. */
145
+ private tryMigrate;
127
146
  }
147
+ export {};
@@ -8,10 +8,10 @@ import { randomBytes } from "node:crypto";
8
8
  import { hostname } from "node:os";
9
9
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
11
- import { BASELINE_VERSION, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
11
+ import { BASELINE_VERSION, hasPendingMigration, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
12
12
  import { writeFileAtomic } from "../core/io.js";
13
13
  import { readStoreArtifact } from "../core/storeArtifact.js";
14
- import { heldLockNonces, judgeSameHostOwner, selfStartToken, startTokenOf } from "../core/procstart.js";
14
+ import { createdAfterLock, heldLockNonces, judgeSameHostOwner, processStartedAt, selfStartToken, startTokenOf } from "../core/procstart.js";
15
15
  /** High-cardinality collections (symbols, edges) are stored as a single
16
16
  * index.json array — there can be thousands, and one file per edge would create
17
17
  * enormous git noise. Curated, low-volume entities (components, decisions, bugs,
@@ -118,6 +118,7 @@ function readRmwOwnerOnce(lock) {
118
118
  host: parsed.host,
119
119
  ...(typeof parsed.nonce === "string" ? { nonce: parsed.nonce } : {}),
120
120
  ...(typeof parsed.start === "string" ? { start: parsed.start } : {}),
121
+ ...(typeof parsed.at === "string" ? { at: parsed.at } : {}),
121
122
  };
122
123
  }
123
124
  catch {
@@ -143,9 +144,14 @@ const RMW_CLAIM_STALE_MS = 10_000;
143
144
  * The owner's recorded process-instance token settles that by string equality
144
145
  * (core/procstart.ts); no clock is compared, because a lock's mtime and a start
145
146
  * time come from different clocks and any skew would "prove" a live owner
146
- * recycled. `readRmwOwner` stays FIRST: it throws on unsafe/hardlinked or
147
- * oversized ownership metadata, and unsafe ownership never licenses removal. */
148
- function rmwLockIsStale(lock) {
147
+ * recycled. Where no token exists (macOS, Windows) a live pid is "unproven",
148
+ * and an AGED lock is still taken over once the OS proves the process now
149
+ * holding that pid was created after the lock's recorded `at` (issue #457) —
150
+ * the same `createdAfterLock` rule as the write lock. `startedAt` is
151
+ * memoized per acquisition by the caller, because the OS probe spawns.
152
+ * `readRmwOwner` stays FIRST: it throws on unsafe/hardlinked or oversized
153
+ * ownership metadata, and unsafe ownership never licenses removal. */
154
+ function rmwLockIsStale(lock, startedAt) {
149
155
  let stat;
150
156
  try {
151
157
  stat = lstatSync(lock);
@@ -157,9 +163,10 @@ function rmwLockIsStale(lock) {
157
163
  }
158
164
  const owner = readRmwOwner(lock);
159
165
  if (owner && owner.host === hostname()) {
160
- // One shared table with src/serve/writelock.ts (core/procstart.ts), so the
161
- // two locks can never drift apart; "age" means nothing was proven and the
162
- // fallback below governs, exactly as for a foreign host.
166
+ // One shared table with src/serve/writelock.ts (core/procstart.ts), and one
167
+ // shared creation-time check for an "unproven" pid, so the two locks apply
168
+ // the same rule; "age" means nothing was proven and the fallback below
169
+ // governs, exactly as for a foreign host.
163
170
  const verdict = judgeSameHostOwner(owner, {
164
171
  alive: rmwPidAlive,
165
172
  self: selfStartToken(),
@@ -168,6 +175,8 @@ function rmwLockIsStale(lock) {
168
175
  selfPid: process.pid,
169
176
  heldNonces: heldLockNonces,
170
177
  });
178
+ if (verdict === "unproven")
179
+ return Date.now() - stat.mtimeMs > 10_000 && createdAfterLock(owner, startedAt);
171
180
  if (verdict !== "age")
172
181
  return verdict === "stale";
173
182
  }
@@ -210,7 +219,7 @@ function rmwLockIsStale(lock) {
210
219
  * so the race persists until every writer on the store is upgraded. Returns true
211
220
  * when the path may now be free.
212
221
  */
213
- function takeOverRmwLock(lock) {
222
+ function takeOverRmwLock(lock, startedAt) {
214
223
  const claim = `${lock}.reclaim`;
215
224
  try {
216
225
  mkdirSync(claim);
@@ -229,7 +238,7 @@ function takeOverRmwLock(lock) {
229
238
  return false;
230
239
  }
231
240
  try {
232
- if (!rmwLockIsStale(lock))
241
+ if (!rmwLockIsStale(lock, startedAt))
233
242
  return false; // re-judged from scratch under the claim
234
243
  rmSync(lock, { recursive: true, force: true });
235
244
  return true;
@@ -253,6 +262,7 @@ function unsafePath(path, reason) {
253
262
  }
254
263
  export class JsonStore {
255
264
  paths;
265
+ lockOpts;
256
266
  _warnedForward = false;
257
267
  /** Memoized validated records per kind. loadAll() is on every read-path method
258
268
  * (why/checkConstraints/fragility/assembleContext…), each of which previously
@@ -265,8 +275,13 @@ export class JsonStore {
265
275
  lexicalRoot;
266
276
  canonicalRoot;
267
277
  lexicalHunch;
268
- constructor(paths) {
278
+ /** `lockOpts.startedAt` is an INTERNAL TEST SEAM, not API: it replaces the OS
279
+ * creation-time probe of the `.rmw-lock` "unproven" rule so the suite can
280
+ * drive it on linux, where the real probe reports nothing. Callers outside
281
+ * this repo must leave it unset — `processStartedAt` is the only sound probe. */
282
+ constructor(paths, lockOpts = {}) {
269
283
  this.paths = paths;
284
+ this.lockOpts = lockOpts;
270
285
  this.lexicalRoot = resolve(paths.root);
271
286
  this.canonicalRoot = realpathSync(this.lexicalRoot);
272
287
  this.lexicalHunch = resolve(paths.hunch);
@@ -666,6 +681,15 @@ export class JsonStore {
666
681
  const lock = join(directory.lexical, ".rmw-lock");
667
682
  const deadline = Date.now() + 2_000;
668
683
  const nonce = randomBytes(8).toString("hex");
684
+ // The creation-time probe spawns; ask it once per pid for this acquisition,
685
+ // not on every poll nor again under the takeover claim (as writelock.ts).
686
+ const createdAt = new Map();
687
+ const probe = this.lockOpts.startedAt ?? processStartedAt;
688
+ const startedAt = (pid) => {
689
+ if (!createdAt.has(pid))
690
+ createdAt.set(pid, probe(pid));
691
+ return createdAt.get(pid);
692
+ };
669
693
  for (;;) {
670
694
  try {
671
695
  mkdirSync(lock);
@@ -676,10 +700,11 @@ export class JsonStore {
676
700
  // writer may exceed the stale-age heuristic while serializing a large
677
701
  // index; its PID must prevent a second writer from taking over. The
678
702
  // process-instance token (when this platform has one) is what lets a
679
- // successor tell that PID apart from a recycled one.
703
+ // successor tell that PID apart from a recycled one; without one, `at`
704
+ // lets it prove the pid's current process was created after this lock.
680
705
  try {
681
706
  const start = selfStartToken();
682
- writeFileAtomic(join(lock, "owner.tmp.json"), JSON.stringify({ pid: process.pid, host: hostname(), nonce, ...(start !== null ? { start } : {}) }));
707
+ writeFileAtomic(join(lock, "owner.tmp.json"), JSON.stringify({ pid: process.pid, host: hostname(), nonce, at: new Date().toISOString(), ...(start !== null ? { start } : {}) }));
683
708
  }
684
709
  catch (error) {
685
710
  try {
@@ -694,7 +719,7 @@ export class JsonStore {
694
719
  catch (error) {
695
720
  if (error.code !== "EEXIST")
696
721
  throw error;
697
- if (rmwLockIsStale(lock) && takeOverRmwLock(lock))
722
+ if (rmwLockIsStale(lock, startedAt) && takeOverRmwLock(lock, startedAt))
698
723
  continue;
699
724
  // The deadline is checked only AFTER an acquire has been attempted: with
700
725
  // the check at the top of the loop, a successful takeover could still be
@@ -917,19 +942,29 @@ export class JsonStore {
917
942
  return names.length;
918
943
  }
919
944
  /** Persist a schema migration: rewrite every LOADABLE record in its current shape.
920
- * A record that still fails validation after migration is kept untouched (never
921
- * deleted) and counted as `skipped`, so migration can't lose data. The caller
922
- * bumps the manifest afterward. */
945
+ * A record that still fails validation after migration (or whose migration
946
+ * throws) is kept in its ORIGINAL shape (never deleted, never half-migrated) and
947
+ * counted as `skipped`, so migration can't lose data. The caller stamps the
948
+ * manifest at the returned `version`: SCHEMA_VERSION only when nothing was
949
+ * skipped, else the FROM version, so a manual fix plus a rerun re-applies the
950
+ * (idempotent) migrations to the skipped records instead of skipping them.
951
+ * A bad record of a kind NO pending migration touches can't be fixed by a
952
+ * rerun, so it is counted as `invalid` instead and doesn't hold the version. */
923
953
  persistMigration() {
924
954
  let migrated = 0;
925
955
  let skipped = 0;
956
+ let invalid = 0;
926
957
  this.cache.clear(); // every record is rewritten; drop all memoized loads
927
958
  const version = this.schemaVersion(); // read once; we're migrating FROM this
928
959
  for (const kind of ENTITY_KINDS) {
929
960
  const directory = this.safeKindDirectory(kind, false);
930
961
  if (!directory)
931
962
  continue;
932
- const schema = SCHEMAS[kind];
963
+ const pending = hasPendingMigration(kind, version);
964
+ const fail = () => { if (pending)
965
+ skipped++;
966
+ else
967
+ invalid++; };
933
968
  const single = SINGLE_FILE[kind];
934
969
  if (single) {
935
970
  const f = join(directory.lexical, single);
@@ -941,23 +976,23 @@ export class JsonStore {
941
976
  arr = JSON.parse(text);
942
977
  }
943
978
  catch {
944
- skipped++;
979
+ fail();
945
980
  continue;
946
981
  }
947
982
  if (!Array.isArray(arr)) {
948
- skipped++;
983
+ fail();
949
984
  continue;
950
985
  }
951
986
  const kept = [];
952
987
  for (const raw of arr) {
953
- const r = schema.safeParse(this.migrate(kind, raw, version));
988
+ const r = this.tryMigrate(kind, raw, version);
954
989
  if (r.success) {
955
990
  kept.push(r.data);
956
991
  migrated++;
957
992
  }
958
993
  else {
959
994
  kept.push(raw); // preserve unmigratable records rather than drop them
960
- skipped++;
995
+ fail();
961
996
  }
962
997
  }
963
998
  this.writeContainedFile(directory, f, encode(kept), this.maxBytes(kind));
@@ -970,24 +1005,38 @@ export class JsonStore {
970
1005
  const text = this.readContainedFile(directory, p, this.maxBytes(kind));
971
1006
  if (text === null)
972
1007
  continue;
1008
+ // A 0-byte merge-driver tombstone holds no record (issue #37) — the
1009
+ // loader's rule; counting it would pin the manifest version forever.
1010
+ if (text.trim() === "")
1011
+ continue;
973
1012
  raw = JSON.parse(text);
974
1013
  }
975
1014
  catch {
976
- skipped++;
1015
+ fail();
977
1016
  continue;
978
1017
  }
979
- const r = schema.safeParse(this.migrate(kind, raw, version));
1018
+ const r = this.tryMigrate(kind, raw, version);
980
1019
  if (r.success) {
981
1020
  this.writeContainedFile(directory, p, encode(r.data), this.maxBytes(kind));
982
1021
  migrated++;
983
1022
  }
984
1023
  else {
985
- skipped++; // leave the file as-is
1024
+ fail(); // leave the file as-is
986
1025
  }
987
1026
  }
988
1027
  }
989
1028
  }
990
- return { migrated, skipped };
1029
+ return { migrated, skipped, invalid, version: skipped ? version : SCHEMA_VERSION };
1030
+ }
1031
+ /** Migrate + validate one raw record for persistMigration; a throwing migration
1032
+ * step counts as a failure, so the record stays in its original shape. */
1033
+ tryMigrate(kind, raw, version) {
1034
+ try {
1035
+ return SCHEMAS[kind].safeParse(this.migrate(kind, raw, version));
1036
+ }
1037
+ catch {
1038
+ return { success: false };
1039
+ }
991
1040
  }
992
1041
  }
993
1042
  //# sourceMappingURL=jsonStore.js.map
@@ -795,15 +795,21 @@ function writeStateAuthorized(store, input, opts, access) {
795
795
  // land its `superseded` event in THIS ledger, invisible to that partition's subscribers.
796
796
  if (!inScope(supersededRecord))
797
797
  throw new StateRefusal("conflict", `supersedes ${request.supersedes} is on record in ${scopePath(recordScope(supersededRecord, repo))}, not ${scopePath(request.scope)}: a write supersedes only a record in its own partition`, { incumbent_id: String(supersededRecord.id), reason: "supersede target in another partition" });
798
+ const matches = typeof request.expected_version === 'number'
799
+ ? latestSeqFor(ledger, String(supersededRecord.id)) === request.expected_version
800
+ : request.expected_version === stateHash(supersededRecord);
798
801
  if (stateHash(supersededVisibility ?? null) !== stateHash(nextVisibility ?? null)) {
799
802
  if (supersededVisibility ? supersededVisibility.owner !== request.principal.id : request.principal.kind !== 'human')
800
803
  deny();
801
- const matches = typeof request.expected_version === 'number'
802
- ? latestSeqFor(readLedger(hunchDir, request.scope), String(supersededRecord.id)) === request.expected_version
803
- : request.expected_version === stateHash(supersededRecord);
804
- if (!matches)
804
+ if (request.expected_version === null)
805
805
  throw new StateRefusal('malformed', 'changing visibility during supersession requires the predecessor expected_version');
806
806
  }
807
+ // A supersession's expected_version names the predecessor (#510): a stale one is a conflict.
808
+ if (request.expected_version !== null && !matches)
809
+ throw new StateRefusal("conflict", `expected_version does not match the superseded ${supersededRecord.id}`, { incumbent_id: String(supersededRecord.id), reason: "expected_version mismatch" });
810
+ }
811
+ else if (request.supersedes && request.expected_version !== null) {
812
+ throw new StateRefusal("conflict", `expected_version names ${request.supersedes}, which is not on record`, { incumbent_id: request.supersedes, reason: "expected_version without incumbent" });
807
813
  }
808
814
  if (!incumbent && !supersededRecord && nextVisibility?.owner !== undefined && nextVisibility.owner !== request.principal.id)
809
815
  deny();
@@ -873,6 +879,13 @@ function writeStateAuthorized(store, input, opts, access) {
873
879
  if (!ok)
874
880
  throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
875
881
  }
882
+ // Compare-and-swap with no incumbent (#510): a writer naming a version believes a record exists,
883
+ // so a fresh create under it is a stale read, never silently accepted. Numeric 0 is the ledger's
884
+ // "never seen" and asserts absence; a supersession's expected_version names its predecessor.
885
+ if (!existing && request.expected_version !== null && !request.supersedes
886
+ && !(typeof request.expected_version === "number" && latestSeqFor(ledger, id) === request.expected_version)) {
887
+ throw new StateRefusal("conflict", `expected_version ${JSON.stringify(request.expected_version)} names a version, but ${id} has none on record: pass null to create it (0 asserts the id was never written)`, { incumbent_id: id, reason: "expected_version without incumbent" });
888
+ }
876
889
  if (replayLink) {
877
890
  const recordHash = stateHash(replayLink);
878
891
  appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, replayLink, request.principal.id), { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);