knodin 0.9.0 → 0.10.1

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.
@@ -0,0 +1,270 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import zlib from "node:zlib";
5
+ import { KNODIN_VERSION } from "../version.js";
6
+ import { Database } from "./sqlite.js";
7
+ /** Storage for embedded source. Content-addressed, so duplicate files cost one blob. */
8
+ const SEAL_SCHEMA = [
9
+ `CREATE TABLE IF NOT EXISTS sealed_source (
10
+ sha256 TEXT PRIMARY KEY,
11
+ bytes BLOB NOT NULL,
12
+ compressed INTEGER NOT NULL,
13
+ storedBytes INTEGER NOT NULL,
14
+ rawBytes INTEGER NOT NULL
15
+ )`,
16
+ `CREATE TABLE IF NOT EXISTS sealed_file (
17
+ filePath TEXT PRIMARY KEY,
18
+ sha256 TEXT NOT NULL REFERENCES sealed_source(sha256)
19
+ )`,
20
+ ];
21
+ /** Line count under the same `split("\n")` convention the read paths use. */
22
+ function countLines(buf) {
23
+ return buf.toString("utf8").split("\n").length;
24
+ }
25
+ export function readSealAttestation(db) {
26
+ try {
27
+ const row = db
28
+ .query("SELECT value FROM meta WHERE key = 'sealAttestation'")
29
+ .get();
30
+ return row ? JSON.parse(row.value) : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * Reads embedded source out of a sealed artifact.
38
+ *
39
+ * Returns `null` — never `""` — for a path the artifact does not carry. An
40
+ * empty string is indistinguishable from an empty file, and downstream that
41
+ * reads as "this symbol has no body" rather than "this was not covered",
42
+ * which is exactly the silent false negative the consumer needs to be able to
43
+ * detect in order to fall back.
44
+ */
45
+ export function readSealedSource(db, filePath) {
46
+ const row = db
47
+ .query("SELECT s.bytes bytes, s.compressed compressed FROM sealed_file f JOIN sealed_source s ON s.sha256 = f.sha256 WHERE f.filePath = ?")
48
+ .get(filePath);
49
+ if (!row)
50
+ return null;
51
+ const raw = Buffer.from(row.bytes);
52
+ return (row.compressed ? zlib.zstdDecompressSync(raw) : raw).toString("utf8");
53
+ }
54
+ /**
55
+ * Every path the sealed artifact carries source for.
56
+ *
57
+ * A sealed repository has no directory tree to walk, so anything that
58
+ * discovers files by traversal (module resolution, package manifests) has to
59
+ * enumerate coverage instead.
60
+ */
61
+ export function listSealedFiles(db) {
62
+ return db
63
+ .query("SELECT filePath FROM sealed_file ORDER BY filePath")
64
+ .all()
65
+ .map((row) => row.filePath);
66
+ }
67
+ /** True when this database carries embedded source, i.e. can answer with no checkout. */
68
+ export function isSealedDatabase(db) {
69
+ return readSealAttestation(db) !== null;
70
+ }
71
+ /**
72
+ * Produce a sealed artifact, or refuse with a machine-readable code.
73
+ *
74
+ * Refusal is the interesting half. A seal of a dirty or diverged index
75
+ * produces citations that look exactly like correct ones — the failure is
76
+ * invisible downstream — so it is refused here rather than annotated.
77
+ */
78
+ export function sealIndex(options) {
79
+ const { repoPath, sourceDatabasePath, outputPath, health, identity, commit, ref, dirtyPaths } = options;
80
+ const stripEmbeddings = options.stripEmbeddings !== false;
81
+ if (!fs.existsSync(sourceDatabasePath))
82
+ return { ok: false, code: "database-missing", reason: `no index at ${sourceDatabasePath}` };
83
+ if (health.status !== "healthy")
84
+ return {
85
+ ok: false,
86
+ code: "index-unhealthy",
87
+ reason: `index status is ${health.status}; seal requires a healthy index`,
88
+ };
89
+ if (dirtyPaths.length > 0)
90
+ return {
91
+ ok: false,
92
+ code: "working-tree-dirty",
93
+ reason: `${dirtyPaths.length} path(s) differ from the index; re-index before sealing`,
94
+ };
95
+ if (!commit)
96
+ return {
97
+ ok: false,
98
+ code: "no-commit",
99
+ reason: "repository has no resolvable commit to attest",
100
+ };
101
+ if (health.pendingPaths > 0)
102
+ return {
103
+ ok: false,
104
+ code: "commit-divergent",
105
+ reason: `${health.pendingPaths} path(s) pending; the graph does not describe ${commit}`,
106
+ };
107
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
108
+ fs.rmSync(outputPath, { force: true });
109
+ // Copy rather than seal in place: the live database keeps serving its own
110
+ // repository, and a half-written artifact can never corrupt it.
111
+ fs.copyFileSync(sourceDatabasePath, outputPath);
112
+ const db = new Database(outputPath);
113
+ try {
114
+ db.run("PRAGMA journal_mode = WAL;");
115
+ for (const statement of SEAL_SCHEMA)
116
+ db.run(statement);
117
+ db.run("DELETE FROM sealed_file;");
118
+ db.run("DELETE FROM sealed_source;");
119
+ let removedEmbeddings = 0;
120
+ if (stripEmbeddings) {
121
+ removedEmbeddings =
122
+ db.query("SELECT COUNT(*) count FROM symbol_embeddings").get()
123
+ ?.count ?? 0;
124
+ db.run("DELETE FROM symbol_embeddings;");
125
+ }
126
+ const indexed = db
127
+ .query("SELECT filePath, mtimeMs, size FROM index_state ORDER BY filePath")
128
+ .all();
129
+ const insertBlob = db.prepare("INSERT OR IGNORE INTO sealed_source (sha256, bytes, compressed, storedBytes, rawBytes) VALUES (?, ?, ?, ?, ?)");
130
+ const insertFile = db.prepare("INSERT OR REPLACE INTO sealed_file (filePath, sha256) VALUES (?, ?)");
131
+ const maxEndLine = new Map();
132
+ for (const row of db
133
+ .query("SELECT filePath, MAX(endLine) lastLine FROM symbols GROUP BY filePath")
134
+ .all())
135
+ maxEndLine.set(row.filePath, row.lastLine);
136
+ const seen = new Set();
137
+ const drifted = [];
138
+ let files = 0;
139
+ let rawBytes = 0;
140
+ let storedBytes = 0;
141
+ db.run("BEGIN TRANSACTION;");
142
+ try {
143
+ for (const row of indexed) {
144
+ if (options.excludeSource?.(row.filePath))
145
+ continue;
146
+ const absolute = path.join(repoPath, row.filePath);
147
+ let buf;
148
+ let stat;
149
+ try {
150
+ stat = fs.statSync(absolute);
151
+ buf = fs.readFileSync(absolute);
152
+ }
153
+ catch {
154
+ // A file that vanished between indexing and sealing is simply not
155
+ // embedded. Queries for it report unavailable rather than empty.
156
+ continue;
157
+ }
158
+ // The drift hazard, and the reason this is a refusal rather than a
159
+ // warning. Seal reads the tree; the graph was built earlier. If a file
160
+ // changed in between, embedded bytes disagree with recorded line
161
+ // ranges and `explain` returns the WRONG function's body with full
162
+ // confidence and no omission — a false positive that looks correct,
163
+ // which is strictly worse than any false negative here.
164
+ if ((row.size !== null && row.size !== stat.size) ||
165
+ (row.mtimeMs !== null && Math.abs(row.mtimeMs - stat.mtimeMs) > 1)) {
166
+ drifted.push(row.filePath);
167
+ continue;
168
+ }
169
+ // Defence in depth behind the mtime/size check above, and the reason
170
+ // it is worth having: mtime+size cannot see a same-size edit that
171
+ // lands inside the mtime comparison window. A range that now runs off
172
+ // the end of the file is proof the graph and these bytes disagree,
173
+ // independent of any timestamp.
174
+ const lastLine = maxEndLine.get(row.filePath);
175
+ if (lastLine !== undefined && lastLine > countLines(buf)) {
176
+ drifted.push(row.filePath);
177
+ continue;
178
+ }
179
+ const hash = crypto.createHash("sha256").update(buf).digest("hex");
180
+ files++;
181
+ rawBytes += buf.length;
182
+ if (!seen.has(hash)) {
183
+ seen.add(hash);
184
+ // Compressed PER BLOB, not just in transit: SQLite does not
185
+ // transparently compress, so a consumer holds whatever is stored
186
+ // here. Transport compression would do nothing for that.
187
+ //
188
+ // Keep the raw bytes when the compressed form is not smaller.
189
+ // zstd framing exceeds the payload on very short files, and
190
+ // storing those compressed would make the artifact larger than
191
+ // the source it embeds.
192
+ const packed = zlib.zstdCompressSync(buf);
193
+ const useCompressed = packed.length < buf.length;
194
+ const stored = useCompressed ? packed : buf;
195
+ storedBytes += stored.length;
196
+ insertBlob.run(hash, stored, useCompressed ? 1 : 0, stored.length, buf.length);
197
+ }
198
+ insertFile.run(row.filePath, hash);
199
+ }
200
+ db.run("COMMIT;");
201
+ }
202
+ catch (error) {
203
+ db.run("ROLLBACK;");
204
+ throw error;
205
+ }
206
+ finally {
207
+ insertBlob.finalize();
208
+ insertFile.finalize();
209
+ }
210
+ if (drifted.length > 0) {
211
+ db.close();
212
+ // Discard the partial artifact rather than shipping one whose coverage
213
+ // silently excludes the very files that moved.
214
+ fs.rmSync(outputPath, { force: true });
215
+ const sample = drifted.slice(0, 5).join(", ");
216
+ return {
217
+ ok: false,
218
+ code: "source-drift",
219
+ reason: `${drifted.length} file(s) changed since indexing (${sample}${drifted.length > 5 ? ", …" : ""}); re-index before sealing`,
220
+ };
221
+ }
222
+ const attestation = {
223
+ sealVersion: 1,
224
+ knodinVersion: KNODIN_VERSION,
225
+ schemaVersion: db.query("PRAGMA user_version").get()?.user_version ?? 0,
226
+ repository: { identity, path: repoPath, remoteUrl: options.remoteUrl ?? null },
227
+ sealedCommit: commit,
228
+ sealedRef: ref,
229
+ sealedAt: new Date().toISOString(),
230
+ health: { ...health },
231
+ source: {
232
+ policy: options.excludeSource ? "excluded" : "all-indexed",
233
+ files,
234
+ uniqueBlobs: seen.size,
235
+ rawBytes,
236
+ storedBytes,
237
+ },
238
+ embeddings: { stripped: stripEmbeddings, removed: removedEmbeddings },
239
+ };
240
+ db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('sealAttestation', ?)", [
241
+ JSON.stringify(attestation),
242
+ ]);
243
+ // Reclaim the pages freed by stripping embeddings and by any deletion
244
+ // above. Without this the artifact keeps every freed page and stripping
245
+ // saves nothing on disk — the whole reason embeddings are dropped. Must
246
+ // run outside a transaction, which is why it lands here rather than
247
+ // alongside the deletes.
248
+ db.run("VACUUM;");
249
+ const integrity = db.query("PRAGMA integrity_check").get()?.integrity_check ??
250
+ "unknown";
251
+ // Fold the WAL back in: a consumer receives ONE file, and an artifact
252
+ // whose committed state lives in a sidecar it never got is a silent
253
+ // truncation rather than a loud failure.
254
+ db.run("PRAGMA wal_checkpoint(TRUNCATE);");
255
+ db.run("PRAGMA journal_mode = DELETE;");
256
+ db.close();
257
+ for (const suffix of ["-wal", "-shm"])
258
+ fs.rmSync(`${outputPath}${suffix}`, { force: true });
259
+ const bytes = fs.statSync(outputPath).size;
260
+ const sha256 = crypto.createHash("sha256").update(fs.readFileSync(outputPath)).digest("hex");
261
+ return { ok: true, outputPath, bytes, attestation, integrityCheck: integrity, sha256 };
262
+ }
263
+ catch (error) {
264
+ try {
265
+ db.close();
266
+ }
267
+ catch { }
268
+ throw error;
269
+ }
270
+ }
@@ -0,0 +1,116 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { KNODIN_VERSION as RUNTIME_VERSION } from "../version.js";
5
+ import { KNODIN_SCHEMA_VERSION, registerSealedRepository, unregisterSealedRepository, } from "./index.js";
6
+ import { readSealAttestation } from "./seal.js";
7
+ import { Database } from "./sqlite.js";
8
+ export function openSealedArtifact(artifactPath, options = {}) {
9
+ const resolved = path.resolve(artifactPath);
10
+ if (!fs.existsSync(resolved))
11
+ return { ok: false, code: "artifact-missing", reason: `no artifact at ${resolved}` };
12
+ let db;
13
+ try {
14
+ db = new Database(resolved, { readonly: true });
15
+ }
16
+ catch (error) {
17
+ return {
18
+ ok: false,
19
+ code: "unreadable",
20
+ reason: error instanceof Error ? error.message : String(error),
21
+ };
22
+ }
23
+ const attestation = readSealAttestation(db);
24
+ if (!attestation) {
25
+ db.close();
26
+ return {
27
+ ok: false,
28
+ code: "not-sealed",
29
+ reason: "database carries no seal attestation; it is an ordinary index, not an artifact",
30
+ };
31
+ }
32
+ // HARD gate. The graph schema is what every query is written against, so a
33
+ // mismatch is not a degradation — the answers would be wrong rather than
34
+ // narrow. A consumer falls back to live source on this code.
35
+ if (attestation.schemaVersion !== KNODIN_SCHEMA_VERSION) {
36
+ db.close();
37
+ return {
38
+ ok: false,
39
+ code: "schema-mismatch",
40
+ reason: `artifact schema ${attestation.schemaVersion} != engine schema ${KNODIN_SCHEMA_VERSION}`,
41
+ };
42
+ }
43
+ const degraded = [];
44
+ // Stripped embeddings must make semantic search report itself UNAVAILABLE.
45
+ // Left to return zero results it would be indistinguishable from "nothing
46
+ // matched", which is the silent false negative this whole design exists to
47
+ // prevent.
48
+ if (attestation.embeddings.stripped)
49
+ degraded.push({
50
+ capability: "semanticSearch",
51
+ reason: "embeddings were stripped at seal time; semantic search cannot run",
52
+ });
53
+ // SOFT gate, and the asymmetry with the schema check is deliberate: a
54
+ // sealed artifact is never re-parsed, so a knodin version that parses
55
+ // differently does not invalidate symbols already extracted.
56
+ if (attestation.knodinVersion !== KNODIN_VERSION_AT_RUNTIME()) {
57
+ if (options.strictCompat) {
58
+ db.close();
59
+ return {
60
+ ok: false,
61
+ code: "schema-mismatch",
62
+ reason: `--strict-compat: artifact knodin ${attestation.knodinVersion} != runtime`,
63
+ };
64
+ }
65
+ degraded.push({
66
+ capability: "review",
67
+ reason: `sealed by knodin ${attestation.knodinVersion}; heuristics may differ`,
68
+ });
69
+ }
70
+ // A sealed artifact has no working tree to write to, and no way to reindex.
71
+ degraded.push({ capability: "rename", reason: "sealed artifacts are read-only" }, { capability: "index", reason: "sealed artifacts cannot be reindexed" });
72
+ const mountDir = options.mountDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "knodin-sealed-mount-"));
73
+ // The artifact lives under `candidates/` so the engine's explicit-database
74
+ // guard accepts it: that guard refuses a path colliding with the active
75
+ // database, which is a candidate-isolation invariant rather than an
76
+ // obstacle to route around.
77
+ const mountedDb = path.join(mountDir, ".knodin", "candidates", "sealed", "db.sqlite");
78
+ fs.mkdirSync(path.dirname(mountedDb), { recursive: true });
79
+ if (!fs.existsSync(mountedDb))
80
+ fs.copyFileSync(resolved, mountedDb);
81
+ // Registered under BOTH paths. Callers address the artifact by its mount
82
+ // directory, but the engine resolves a repository from paths recorded in
83
+ // the database itself — the checkout the index was built from. Registering
84
+ // only the mount would leave those internal reads falling through to a
85
+ // filesystem that does not have the source, which fails as "" rather than
86
+ // loudly.
87
+ registerSealedRepository(mountDir, db);
88
+ const attestedPath = attestation.repository.path;
89
+ if (attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir))
90
+ registerSealedRepository(attestedPath, db);
91
+ const sealedAt = Date.parse(attestation.sealedAt);
92
+ const ageDays = Number.isNaN(sealedAt)
93
+ ? 0
94
+ : Math.floor((Date.now() - sealedAt) / (24 * 60 * 60 * 1000));
95
+ return {
96
+ ok: true,
97
+ repoPath: mountDir,
98
+ databasePath: mountedDb,
99
+ attestation,
100
+ degraded,
101
+ ageDays,
102
+ close() {
103
+ unregisterSealedRepository(mountDir);
104
+ if (attestedPath)
105
+ unregisterSealedRepository(attestedPath);
106
+ db.close();
107
+ },
108
+ };
109
+ }
110
+ /**
111
+ * Read at call time rather than imported as a constant so that a test can
112
+ * exercise the soft gate without rebuilding the package version.
113
+ */
114
+ function KNODIN_VERSION_AT_RUNTIME() {
115
+ return process.env.KNODIN_VERSION_OVERRIDE ?? RUNTIME_VERSION;
116
+ }
@@ -0,0 +1,49 @@
1
+ import { createEngine } from "./index.js";
2
+ import { openSealedArtifact, } from "./sealed-open.js";
3
+ export async function runSealedQuery(artifactPath, symbol, options = {}) {
4
+ const handle = openSealedArtifact(artifactPath, { strictCompat: options.strictCompat });
5
+ if (!handle.ok)
6
+ return { ok: false, code: handle.code, reason: handle.reason };
7
+ try {
8
+ const summary = {
9
+ ok: true,
10
+ repository: {
11
+ identity: handle.attestation.repository.identity,
12
+ commit: handle.attestation.sealedCommit,
13
+ ref: handle.attestation.sealedRef,
14
+ },
15
+ sealedAt: handle.attestation.sealedAt,
16
+ ageDays: handle.ageDays,
17
+ knodinVersion: handle.attestation.knodinVersion,
18
+ schemaVersion: handle.attestation.schemaVersion,
19
+ coverage: {
20
+ files: handle.attestation.source.files,
21
+ uniqueBlobs: handle.attestation.source.uniqueBlobs,
22
+ rawBytes: handle.attestation.source.rawBytes,
23
+ storedBytes: handle.attestation.source.storedBytes,
24
+ },
25
+ degraded: handle.degraded,
26
+ };
27
+ if (!symbol)
28
+ return summary;
29
+ // `mode: "sealed"` is the whole reason this wrapper exists. Without it the
30
+ // engine reconciles against the empty mount, prunes every symbol, and
31
+ // answers "not found" for content the artifact is carrying — a negative
32
+ // that looks entirely correct. Too important to leave to each caller.
33
+ const engine = createEngine({
34
+ watcher: "disabled",
35
+ mode: "sealed",
36
+ databasePath: handle.databasePath,
37
+ });
38
+ try {
39
+ summary.explained = await engine.explain(symbol, handle.repoPath);
40
+ return summary;
41
+ }
42
+ finally {
43
+ await engine.close();
44
+ }
45
+ }
46
+ finally {
47
+ handle.close();
48
+ }
49
+ }
package/dist/src/init.js CHANGED
@@ -7,7 +7,7 @@ import { compareBytes } from "./compare.js";
7
7
  import { isIndexableSourcePath } from "./engine/source-policy.js";
8
8
  import { lookupMirror } from "./engine/state-paths.js";
9
9
  import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
10
- import { acquireRepairLease } from "./repair-lease.js";
10
+ import { acquireRepairLease, LIFECYCLE_LEASE_TOKEN_ENV } from "./repair-lease.js";
11
11
  import { installKnodinSkills, removeKnodinSkills } from "./skill-management.js";
12
12
  import { registerInitializedWorktree } from "./worktree-lifecycle.js";
13
13
  const MANAGED_MARKER = "KNODIN MANAGED HOOK";
@@ -43,6 +43,13 @@ function runGit(repo, args, encoding = "utf-8") {
43
43
  cwd: repo,
44
44
  encoding,
45
45
  stdio: ["ignore", "pipe", "ignore"],
46
+ // A commit/checkout/merge touching many files (a large monorepo, an
47
+ // initial import, a mass rename) can produce a path list well past
48
+ // Node's 1 MiB execFileSync default, which throws rather than
49
+ // truncates. Match the 64 MiB bound already used for git output
50
+ // elsewhere in this codebase (see gitTrackedCandidates,
51
+ // execGitAbortable in src/engine/index.ts).
52
+ maxBuffer: 64 * 1024 * 1024,
46
53
  });
47
54
  }
48
55
  const MUTATION_AUDIT_MAX_ENTRIES = 2_048;
@@ -264,6 +271,24 @@ function diffPaths(repo, before, after) {
264
271
  }
265
272
  return nulPaths(repo, ["diff", "--name-only", before, after, "--"]);
266
273
  }
274
+ /**
275
+ * Runs a git-diff-shaped path computation and degrades to "no known changes"
276
+ * on failure instead of throwing. A lifecycle hook process crashing on an
277
+ * oversized or otherwise-failing git subprocess call has historically taken
278
+ * down the whole background refresh (exit code 1, nothing indexed at all);
279
+ * the per-query freshness guard and the next reconcile pass already detect
280
+ * and catch up any drift this misses, so failing soft here is strictly safer
281
+ * than failing hard.
282
+ */
283
+ function gitPathsOrEmpty(compute, context) {
284
+ try {
285
+ return compute();
286
+ }
287
+ catch (error) {
288
+ console.warn(`knodin: lifecycle hook refresh could not compute changed paths for ${context} (${error instanceof Error ? error.message : String(error)}); the next reconcile will catch up any drift.`);
289
+ return [];
290
+ }
291
+ }
267
292
  /**
268
293
  * Resolve changed paths from a Git lifecycle event and incrementally reconcile
269
294
  * exactly the candidates accepted by the engine's full-repository policy.
@@ -274,17 +299,17 @@ export async function refreshFromGitEvent(repo, event, index) {
274
299
  let rewriteInputToRemove;
275
300
  try {
276
301
  if (event.kind === "commit") {
277
- changed = nulPaths(resolvedRepo, [
302
+ changed = gitPathsOrEmpty(() => nulPaths(resolvedRepo, [
278
303
  "diff-tree",
279
304
  "--root",
280
305
  "--no-commit-id",
281
306
  "--name-only",
282
307
  "-r",
283
308
  "HEAD",
284
- ]);
309
+ ]), "a commit event");
285
310
  }
286
311
  else if (event.kind === "checkout" || event.kind === "merge") {
287
- changed = diffPaths(resolvedRepo, event.before, event.after);
312
+ changed = gitPathsOrEmpty(() => diffPaths(resolvedRepo, event.before, event.after), `a ${event.kind} event`);
288
313
  }
289
314
  else {
290
315
  const hooksRoot = path.join(resolvedRepo, ".knodin", "hooks");
@@ -316,7 +341,7 @@ export async function refreshFromGitEvent(repo, event, index) {
316
341
  .map((line) => line.trim().split(/\s+/))
317
342
  .filter((pair) => pair.length >= 2);
318
343
  for (const [before, after] of pairs) {
319
- changed.push(...diffPaths(resolvedRepo, before, after));
344
+ changed.push(...gitPathsOrEmpty(() => diffPaths(resolvedRepo, before, after), "a rewrite pair"));
320
345
  }
321
346
  }
322
347
  const eligible = [...new Set(changed.map((file) => file.replaceAll("\\", "/")))]
@@ -332,10 +357,29 @@ export async function refreshFromGitEvent(repo, event, index) {
332
357
  }
333
358
  }
334
359
  function backgroundScript(command) {
335
- const invocation = command.map(shellQuote).join(" ");
360
+ // The interpreter is resolved at RUN time, not baked in. `process.execPath`
361
+ // is symlink-resolved by Node, so a stable `/opt/homebrew/bin/node` becomes
362
+ // a version-pinned `/opt/homebrew/Cellar/node/<version>/bin/node`. Homebrew
363
+ // deletes that directory on upgrade, and every hook generated beforehand
364
+ // then dies with exit 127 — silently, because refresh runs in the
365
+ // background, so the graph just stops keeping up.
366
+ //
367
+ // The generated path stays PREFERRED, so nothing changes while it exists;
368
+ // the fallback only engages once it does not. Falling back first would
369
+ // silently switch which runtime executes, which is its own bug.
370
+ const [interpreter, ...rest] = command;
371
+ const invocation = ['"$KNODIN_NODE"', ...rest.map(shellQuote)].join(" ");
372
+ const preferred = shellQuote(interpreter ?? process.execPath);
336
373
  return String.raw `#!/bin/sh
337
374
  # knodin packaged background refresh. Generated by knodin init.
338
375
  set -u
376
+ KNODIN_NODE=${preferred}
377
+ if [ ! -x "$KNODIN_NODE" ]; then
378
+ KNODIN_NODE="$(command -v node 2>/dev/null || true)"
379
+ # No interpreter at all: fall through to the recorded path so the failure is
380
+ # the existing loud "not found", not a silent no-op.
381
+ [ -n "$KNODIN_NODE" ] || KNODIN_NODE=${preferred}
382
+ fi
339
383
  REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
340
384
  [ -n "$REPO_ROOT" ] || exit 0
341
385
  cd "$REPO_ROOT" || exit 0
@@ -435,7 +479,7 @@ function refreshProcessorIsRunning(repo) {
435
479
  return false;
436
480
  }
437
481
  }
438
- function drainQueuedLifecycleEvents(repo, backgroundPath) {
482
+ function drainQueuedLifecycleEvents(repo, backgroundPath, leaseToken) {
439
483
  const before = queuedLifecycleEvents(repo);
440
484
  if (before.length === 0)
441
485
  return { state: "fresh", queuedEvents: 0 };
@@ -444,6 +488,13 @@ function drainQueuedLifecycleEvents(repo, backgroundPath) {
444
488
  encoding: "utf-8",
445
489
  stdio: ["ignore", "pipe", "pipe"],
446
490
  timeout: 30_000,
491
+ // The caller holds the lifecycle lease while this runs. Without handing
492
+ // the token down, the child re-enters knodin, meets its own parent's
493
+ // lease, and refuses — so the drain could never succeed and init
494
+ // reported failure on every run.
495
+ env: leaseToken
496
+ ? { ...process.env, [LIFECYCLE_LEASE_TOKEN_ENV]: leaseToken }
497
+ : { ...process.env },
447
498
  });
448
499
  if (result.status !== 0) {
449
500
  const detail = result.error?.message || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
@@ -1264,7 +1315,7 @@ export async function initializeRepository(repo, options) {
1264
1315
  if (isIndexResult(indexResult) && indexResult.verification.status !== "healthy") {
1265
1316
  throw new InitializationHealthError(indexResult);
1266
1317
  }
1267
- const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath);
1318
+ const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
1268
1319
  await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), {
1269
1320
  force: true,
1270
1321
  });
@@ -200,8 +200,19 @@ export class RepositoryWorker extends EventEmitter {
200
200
  }
201
201
  const { requestId, operation } = context;
202
202
  try {
203
+ // Same race as the startup one below, one phase earlier: the queueAbort
204
+ // arm above reports CLIENT_DISCONNECTED, and this re-check reported
205
+ // CANCELLED for the identical event whenever the queue wait resolved in
206
+ // the same instant the abort landed.
207
+ //
208
+ // The supervisor observes only "the signal aborted" and cannot tell a
209
+ // disconnect from an explicit cancel; every abort-listener arm here
210
+ // already calls that a disconnect, so the re-checks now agree with them.
211
+ // The distinction belongs to the layer that knows why the signal fired —
212
+ // superviseMcpRequest, which raises REQUEST_CANCELLED for a client
213
+ // cancellation it can actually identify.
203
214
  if (signal.aborted)
204
- throw codedError("KNODIN_REQUEST_CANCELLED", "request cancelled before worker dispatch");
215
+ throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker dispatch");
205
216
  if (!this.child) {
206
217
  if (this.restartTimes.length >= MAX_RESTARTS)
207
218
  throw codedError("KNODIN_RESTART_LIMIT", "graph worker restart limit reached");
@@ -233,8 +244,20 @@ export class RepositoryWorker extends EventEmitter {
233
244
  if (startupAbort)
234
245
  signal.removeEventListener("abort", startupAbort);
235
246
  });
247
+ // Same event as the startupAbort arm above, same code. Both describe an
248
+ // abort arriving during worker startup; which one reports it is decided
249
+ // by whether the startup race settles as ready or as aborted in the same
250
+ // instant. Reporting different codes made the SAME client disconnect
251
+ // surface as CLIENT_DISCONNECTED or REQUEST_CANCELLED depending on
252
+ // scheduling — stable on an idle machine, unstable under load, which is
253
+ // worse than a consistently wrong value because it invites trust.
254
+ //
255
+ // These codes name a CAUSE, not a phase. classifyMcpFailure maps them to
256
+ // distinct kinds in the reliability journal, so the race was also
257
+ // recording one disconnect under two kinds and silently contaminating
258
+ // any disconnect-versus-cancellation rate computed from it.
236
259
  if (signal.aborted)
237
- throw codedError("KNODIN_REQUEST_CANCELLED", "request cancelled during worker startup");
260
+ throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected during worker startup");
238
261
  const predecessorTraceId = this.lastFailureTraceId;
239
262
  if (predecessorTraceId)
240
263
  recordMcpLifecycle(context, "restart", { predecessorTraceId });
@@ -92,10 +92,34 @@ function removeStaleLeaseOrThrow(target, operation) {
92
92
  throw error;
93
93
  }
94
94
  }
95
+ /**
96
+ * Environment channel by which a lease holder tells a process it spawns that
97
+ * the lease it is about to meet is its OWN.
98
+ *
99
+ * `knodin init` holds the lease and then drains the queued lifecycle refresh by
100
+ * spawning the background hook, which re-enters knodin and tries to take the
101
+ * same lease. Without this the child sees a live pid — its own parent — and
102
+ * refuses, so init can never drain its own queue and reports failure on every
103
+ * run while telling the user to run init again.
104
+ */
105
+ export const LIFECYCLE_LEASE_TOKEN_ENV = "KNODIN_LIFECYCLE_LEASE_TOKEN";
95
106
  /** Atomically serialize repair post-processing across independent CLI/MCP processes. */
96
107
  export function acquireRepairLease(repo, operation = "repair") {
97
108
  const target = leasePath(repo);
98
109
  fs.mkdirSync(path.dirname(target), { recursive: true });
110
+ // Re-entrant ONLY for a token that matches the lease actually on disk. A
111
+ // stale or forged token falls through to the normal contention check, so a
112
+ // genuinely concurrent mutation from another process is still refused —
113
+ // this makes the lease stop blocking its own holder, it does not disable it.
114
+ const inheritedToken = process.env[LIFECYCLE_LEASE_TOKEN_ENV];
115
+ if (inheritedToken) {
116
+ const held = readLease(target);
117
+ if (held?.nonce === inheritedToken) {
118
+ // The holder releases it. Releasing here would drop the lock while the
119
+ // parent still believes it holds it.
120
+ return { release() { } };
121
+ }
122
+ }
99
123
  const lifecycleCommand = [
100
124
  "repair",
101
125
  "init",
@@ -124,6 +148,7 @@ export function acquireRepairLease(repo, operation = "repair") {
124
148
  writeLease(target, record);
125
149
  writeActivity(repo, record);
126
150
  return {
151
+ token: record.nonce,
127
152
  release() {
128
153
  try {
129
154
  const current = readLease(target);
@@ -55,7 +55,7 @@ async function objectBytes(store, key, maxBytes, signal) {
55
55
  throw new SharedIndexError("integrity", "shared-index object length changed during download");
56
56
  return bytes;
57
57
  }
58
- function commitRelation(repo, head, commit) {
58
+ export function commitRelation(repo, head, commit) {
59
59
  if (commit === head)
60
60
  return { relation: "exact", distance: 0 };
61
61
  const ancestor = git(repo, ["merge-base", "--is-ancestor", commit, head]);