knodin 0.10.0 → 0.10.2
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.
- package/dist/bin/cli.js +22 -6
- package/dist/src/engine/index.js +266 -75
- package/dist/src/engine/parse-worker.js +19 -6
- package/dist/src/init.js +105 -9
- package/dist/src/mcp-worker-supervisor.js +25 -2
- package/dist/src/repair-lease.js +25 -0
- package/docs/releases/0.10.1.md +83 -0
- package/docs/releases/0.10.2.md +76 -0
- package/package.json +3 -1
package/dist/bin/cli.js
CHANGED
|
@@ -27,7 +27,7 @@ import { exportContext, grepPackedArtifact, readPackedArtifact } from "../src/co
|
|
|
27
27
|
import { clearDiagnostics, collectDiagnostics, diagnosticsStatus, disableDiagnostics, enableDiagnostics, inspectDiagnosticsBundle, persistDiagnosticsPreview, recordDiagnosticFailure, } from "../src/diagnostics.js";
|
|
28
28
|
import { getDocSection, listDocTopics } from "../src/docs-sections.js";
|
|
29
29
|
import { diagnoseInstallation } from "../src/doctor.js";
|
|
30
|
-
import { createEngine, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
|
|
30
|
+
import { createEngine, describeThrown, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
|
|
31
31
|
import { runSeal } from "../src/engine/seal-command.js";
|
|
32
32
|
import { runSealedQuery } from "../src/engine/sealed-query.js";
|
|
33
33
|
import { resolveDbPath } from "../src/engine/state-paths.js";
|
|
@@ -199,10 +199,16 @@ function formatCoverageGaps(skipped) {
|
|
|
199
199
|
if (byExtension)
|
|
200
200
|
clauses.push(`not indexed: ${byExtension}`);
|
|
201
201
|
const unparsed = formatTally(skipped.unparsedByExtension);
|
|
202
|
+
// "not parsed", not "parsed but empty". Nothing in this tally was parsed:
|
|
203
|
+
// it collects files with no tree-sitter grammar, minified sources, and files
|
|
204
|
+
// whose indexing threw. Calling that "parsed but empty" asserts the parser
|
|
205
|
+
// looked and found nothing, which is the one thing it did not do — and it
|
|
206
|
+
// made a real outage unreadable, since a dead wasm module dumped thousands
|
|
207
|
+
// of never-parsed files into this bucket under a label saying they were fine.
|
|
202
208
|
if (unparsed)
|
|
203
|
-
clauses.push(`parsed
|
|
209
|
+
clauses.push(`not parsed: ${unparsed}`);
|
|
204
210
|
if (skipped.unparsedUnknown)
|
|
205
|
-
clauses.push("parsed
|
|
211
|
+
clauses.push("not-parsed tally unavailable — run a full index; this is a lower bound");
|
|
206
212
|
const count = skipped.unparsedUnknown ? `${skipped.total}+` : `${skipped.total}`;
|
|
207
213
|
return `. Not in graph: ${count} files (${clauses.join("; ")})`;
|
|
208
214
|
}
|
|
@@ -2124,17 +2130,27 @@ async function main() {
|
|
|
2124
2130
|
const agents = scope === "team" ? [] : integrationAgents(repo);
|
|
2125
2131
|
const paths = await initializeRepository(repo, {
|
|
2126
2132
|
command: runtimeCommand,
|
|
2127
|
-
|
|
2133
|
+
// Not `indexOrSeed`: seeding exists for a worktree with no database,
|
|
2134
|
+
// and this command has already refused to run without one. Indexing
|
|
2135
|
+
// the written files directly is the whole point of the narrow scope.
|
|
2136
|
+
index: (target, indexOptions, files) => engine.index(target, files, false, indexOptions),
|
|
2137
|
+
indexScope: "configuration",
|
|
2128
2138
|
scope,
|
|
2129
2139
|
agents,
|
|
2130
2140
|
allowTrackedTransition: true,
|
|
2131
2141
|
auditConfigurationChanges: true,
|
|
2132
2142
|
configureIntegration: true,
|
|
2133
2143
|
});
|
|
2144
|
+
const indexed = paths.scopedIndexPaths ?? [];
|
|
2134
2145
|
result = {
|
|
2135
2146
|
status: "success",
|
|
2136
2147
|
message: `knodin agent integration changed to ${scope}.`,
|
|
2137
|
-
|
|
2148
|
+
// Say what happened. Claiming "refreshed" after touching a few
|
|
2149
|
+
// configuration files misdescribes the graph's state, and did so most
|
|
2150
|
+
// misleadingly on a repository whose graph was in fact empty.
|
|
2151
|
+
graphInitialization: indexed.length === 0
|
|
2152
|
+
? "unchanged — no configuration files needed indexing"
|
|
2153
|
+
: `configuration files indexed (${indexed.length})`,
|
|
2138
2154
|
nextAction: "run `knodin status` and reload the configured client",
|
|
2139
2155
|
paths,
|
|
2140
2156
|
};
|
|
@@ -2985,7 +3001,7 @@ catch (err) {
|
|
|
2985
3001
|
error: err,
|
|
2986
3002
|
});
|
|
2987
3003
|
const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
|
|
2988
|
-
console.error(`${
|
|
3004
|
+
console.error(`${describeThrown(err)}${correlation}`);
|
|
2989
3005
|
process.exit(1);
|
|
2990
3006
|
}
|
|
2991
3007
|
}
|
package/dist/src/engine/index.js
CHANGED
|
@@ -4938,13 +4938,22 @@ async function indexRazorFile(content, relativePath, _repoPath, db) {
|
|
|
4938
4938
|
const parser = new Parser();
|
|
4939
4939
|
parser.setLanguage(language);
|
|
4940
4940
|
const tree = parser.parse(projected);
|
|
4941
|
-
if (!tree)
|
|
4941
|
+
if (!tree) {
|
|
4942
|
+
parser.delete();
|
|
4942
4943
|
return;
|
|
4944
|
+
}
|
|
4943
4945
|
// `filePath` drives the extractor's language-specific branches, so it must
|
|
4944
4946
|
// look like C#; the rows are persisted against the real Razor path below.
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4947
|
+
// The `finally` matters: a throw out of the extractor previously leaked both
|
|
4948
|
+
// objects, on exactly the malformed inputs most likely to throw.
|
|
4949
|
+
let extracted;
|
|
4950
|
+
try {
|
|
4951
|
+
extracted = extractSymbolsAndReferences(tree.rootNode, false, false, "razor-projection.cs");
|
|
4952
|
+
}
|
|
4953
|
+
finally {
|
|
4954
|
+
freeQuietly(tree);
|
|
4955
|
+
freeQuietly(parser);
|
|
4956
|
+
}
|
|
4948
4957
|
// The synthetic wrapper class exists only to make members parseable.
|
|
4949
4958
|
const definitions = extracted.definitions.filter((def) => def.name !== "__RazorCode");
|
|
4950
4959
|
db.run("BEGIN TRANSACTION;");
|
|
@@ -6178,6 +6187,58 @@ const INDEX_WRITE_BATCH_SIZE = (() => {
|
|
|
6178
6187
|
const raw = Number(process.env.KNODIN_INDEX_WRITE_BATCH_SIZE);
|
|
6179
6188
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 200;
|
|
6180
6189
|
})();
|
|
6190
|
+
/**
|
|
6191
|
+
* True for an Emscripten abort: the wasm module itself calling `exit()`.
|
|
6192
|
+
*
|
|
6193
|
+
* This is categorically different from "this file did not parse" and must never
|
|
6194
|
+
* be handled by a per-file recovery path. The module does not come back — every
|
|
6195
|
+
* subsequent parse, even on a freshly constructed `Parser`, fails with the same
|
|
6196
|
+
* error. Treating it per-file turns one real failure into thousands of
|
|
6197
|
+
* misattributed ones against innocent files, and still reports the run as
|
|
6198
|
+
* successful, so the graph ends up quietly missing everything after the abort.
|
|
6199
|
+
*/
|
|
6200
|
+
export function isWasmAbort(error) {
|
|
6201
|
+
return (typeof error === "object" &&
|
|
6202
|
+
error !== null &&
|
|
6203
|
+
error.name === "ExitStatus");
|
|
6204
|
+
}
|
|
6205
|
+
/**
|
|
6206
|
+
* Render any throwable as a message, including one that is not an `Error`.
|
|
6207
|
+
*
|
|
6208
|
+
* `error instanceof Error ? error.message : String(error)` is the reflex, and it
|
|
6209
|
+
* is wrong for exactly the failure this release exists to surface: Emscripten's
|
|
6210
|
+
* `ExitStatus` is a plain object carrying `name`/`message`/`status`, so the
|
|
6211
|
+
* ternary falls through to `String(error)` and prints `[object Object]` —
|
|
6212
|
+
* destroying the diagnosis at the last step, in the CLI's top-level handler and
|
|
6213
|
+
* in the parse worker's hand-serialization alike.
|
|
6214
|
+
*/
|
|
6215
|
+
export function describeThrown(error) {
|
|
6216
|
+
if (error instanceof Error)
|
|
6217
|
+
return error.message;
|
|
6218
|
+
if (typeof error === "object" && error !== null) {
|
|
6219
|
+
const { name, message } = error;
|
|
6220
|
+
if (typeof message === "string" && message.length > 0)
|
|
6221
|
+
return typeof name === "string" && name.length > 0 ? `${name}: ${message}` : message;
|
|
6222
|
+
}
|
|
6223
|
+
return String(error);
|
|
6224
|
+
}
|
|
6225
|
+
/**
|
|
6226
|
+
* Free a wasm object without letting the free itself throw.
|
|
6227
|
+
*
|
|
6228
|
+
* Used only where the free sits in a `finally`. If the module has aborted, its
|
|
6229
|
+
* heap is already gone and `delete()` throws too — from a `finally` that
|
|
6230
|
+
* replaces the in-flight error with a second, less informative one, losing the
|
|
6231
|
+
* cause at exactly the moment it matters. There is nothing to reclaim from a
|
|
6232
|
+
* dead module anyway, so dropping that failure loses nothing real.
|
|
6233
|
+
*/
|
|
6234
|
+
function freeQuietly(target) {
|
|
6235
|
+
try {
|
|
6236
|
+
target.delete();
|
|
6237
|
+
}
|
|
6238
|
+
catch {
|
|
6239
|
+
// Intentionally ignored; see above.
|
|
6240
|
+
}
|
|
6241
|
+
}
|
|
6181
6242
|
/**
|
|
6182
6243
|
* Read, parse, and extract one file through the generic tree-sitter path.
|
|
6183
6244
|
*
|
|
@@ -6212,40 +6273,78 @@ export async function extractGenericFileForIndex(absolutePath, relativePath, rep
|
|
|
6212
6273
|
tree = measurePerfPhaseSync("parse_extract", () => parser.parse(parseContent));
|
|
6213
6274
|
}
|
|
6214
6275
|
catch (e) {
|
|
6276
|
+
// Rethrow BEFORE attempting to free: once the module has aborted its heap
|
|
6277
|
+
// is gone, there is nothing left to reclaim, and calling delete() would
|
|
6278
|
+
// throw a second ExitStatus that replaces the real cause.
|
|
6279
|
+
if (isWasmAbort(e))
|
|
6280
|
+
throw e;
|
|
6281
|
+
// Otherwise free before returning. Skipping one file must not also cost a
|
|
6282
|
+
// parser, or a repository full of pathological inputs would leak its way
|
|
6283
|
+
// to an abort through the recovery path itself.
|
|
6284
|
+
freeQuietly(parser);
|
|
6215
6285
|
console.warn(`knodin: skipped ${relativePath} (parser error: ${e.message})`);
|
|
6216
6286
|
return { kind: "skipped", reason: e.message };
|
|
6217
6287
|
}
|
|
6218
|
-
if (!tree)
|
|
6288
|
+
if (!tree) {
|
|
6289
|
+
freeQuietly(parser);
|
|
6219
6290
|
return { kind: "skipped", reason: "parser returned no tree" };
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
absolutePath
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6291
|
+
}
|
|
6292
|
+
// Everything from here on can throw — `extractMcpToolRegistrations` and the
|
|
6293
|
+
// symbol extractor both walk attacker-shaped input — and this runs once per
|
|
6294
|
+
// file, so a leak on the throwing path scales with repository size exactly
|
|
6295
|
+
// like the leak this whole change removes. The `finally` is the guarantee;
|
|
6296
|
+
// `handedOff` is the one case where the tree deliberately escapes.
|
|
6297
|
+
let handedOff = false;
|
|
6298
|
+
try {
|
|
6299
|
+
const mcpRegistrations = /\.[cm]?[jt]sx?$/.test(absolutePath) && /@modelcontextprotocol\/sdk/.test(content)
|
|
6300
|
+
? extractMcpToolRegistrations(content, relativePath, tree.rootNode)
|
|
6301
|
+
: [];
|
|
6302
|
+
const isPython = absolutePath.endsWith(".py");
|
|
6303
|
+
const isApex = absolutePath.endsWith(".cls") || absolutePath.endsWith(".trigger");
|
|
6304
|
+
const isVisualforce = absolutePath.endsWith(".page") || absolutePath.endsWith(".component");
|
|
6305
|
+
const isPrisma = absolutePath.endsWith(".prisma");
|
|
6306
|
+
const isWorkday = absolutePath.endsWith(".clp") ||
|
|
6307
|
+
absolutePath.endsWith(".ws") ||
|
|
6308
|
+
(absolutePath.endsWith(".xml") && isWorkdayStudioFile(content, absolutePath));
|
|
6309
|
+
const special = (route) => {
|
|
6310
|
+
handedOff = true;
|
|
6311
|
+
freeQuietly(parser);
|
|
6312
|
+
return {
|
|
6313
|
+
kind: "special",
|
|
6314
|
+
route,
|
|
6315
|
+
content,
|
|
6316
|
+
parseContent,
|
|
6317
|
+
tree: tree,
|
|
6318
|
+
};
|
|
6319
|
+
};
|
|
6320
|
+
// The parser has done its job by here and nothing downstream needs it: a
|
|
6321
|
+
// `Tree` owns its own wasm memory and stays readable after its parser is
|
|
6322
|
+
// freed (pinned by parser-lifetime.spec.ts, because a use-after-free here
|
|
6323
|
+
// would surface as corrupt nodes rather than a crash). Freeing it at the
|
|
6324
|
+
// point of return is what makes the escaping-tree routes below safe to
|
|
6325
|
+
// hand out — the caller then owns exactly one object, the tree.
|
|
6326
|
+
if (isWorkday)
|
|
6327
|
+
return special("workday");
|
|
6328
|
+
if (isVisualforce)
|
|
6329
|
+
return special("visualforce");
|
|
6330
|
+
if (isSql)
|
|
6331
|
+
return special("sql");
|
|
6332
|
+
if (isPrisma)
|
|
6333
|
+
return special("prisma");
|
|
6334
|
+
// `FileIndexResult` is plain data — it must be, since it is structured-cloned
|
|
6335
|
+
// out of a parse worker — so both objects are dead the moment it is built,
|
|
6336
|
+
// and the `finally` below frees them.
|
|
6337
|
+
return {
|
|
6338
|
+
kind: "result",
|
|
6339
|
+
result: buildFileIndexResult(tree, content, isPython, isApex, absolutePath, relativePath, repoPath, mcpRegistrations),
|
|
6340
|
+
};
|
|
6341
|
+
}
|
|
6342
|
+
finally {
|
|
6343
|
+
if (!handedOff) {
|
|
6344
|
+
freeQuietly(tree);
|
|
6345
|
+
freeQuietly(parser);
|
|
6346
|
+
}
|
|
6347
|
+
}
|
|
6249
6348
|
}
|
|
6250
6349
|
/**
|
|
6251
6350
|
* The single source of truth for indexer routing.
|
|
@@ -6405,20 +6504,35 @@ async function indexFile(absolutePath, relativePath, repoPath, db, unparsed, col
|
|
|
6405
6504
|
return;
|
|
6406
6505
|
if (extraction.kind === "special") {
|
|
6407
6506
|
const { content, parseContent, tree } = extraction;
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6507
|
+
// The tree outlived the function that parsed it, so this is the only
|
|
6508
|
+
// place that can free it. `finally` rather than a delete per branch,
|
|
6509
|
+
// because a throw inside an indexer would otherwise leak silently on
|
|
6510
|
+
// exactly the inputs most likely to throw.
|
|
6511
|
+
//
|
|
6512
|
+
// The cases `break` rather than `return` so the single exit below is
|
|
6513
|
+
// reachable. Returning from inside the `try` left that trailing
|
|
6514
|
+
// statement unreachable — dead code the compiler flagged and coverage
|
|
6515
|
+
// could never account for.
|
|
6516
|
+
try {
|
|
6517
|
+
switch (extraction.route) {
|
|
6518
|
+
case "workday":
|
|
6519
|
+
await indexWorkdayFile(content, tree, relativePath, repoPath, db);
|
|
6520
|
+
break;
|
|
6521
|
+
case "visualforce":
|
|
6522
|
+
await indexVisualforceFile(content, tree, relativePath, repoPath, db);
|
|
6523
|
+
break;
|
|
6524
|
+
case "sql":
|
|
6525
|
+
await indexSqlFile(parseContent, tree, relativePath, repoPath, db);
|
|
6526
|
+
break;
|
|
6527
|
+
case "prisma":
|
|
6528
|
+
await indexPrismaFile(content, tree, relativePath, repoPath, db);
|
|
6529
|
+
break;
|
|
6530
|
+
}
|
|
6421
6531
|
}
|
|
6532
|
+
finally {
|
|
6533
|
+
freeQuietly(tree);
|
|
6534
|
+
}
|
|
6535
|
+
return;
|
|
6422
6536
|
}
|
|
6423
6537
|
if (collectResult) {
|
|
6424
6538
|
collectResult(extraction.result);
|
|
@@ -6428,6 +6542,13 @@ async function indexFile(absolutePath, relativePath, repoPath, db, unparsed, col
|
|
|
6428
6542
|
}
|
|
6429
6543
|
}
|
|
6430
6544
|
catch (error) {
|
|
6545
|
+
// A dead wasm module is not this file's fault and is not survivable.
|
|
6546
|
+
// Swallowing it here is what made one abort look like thousands of
|
|
6547
|
+
// broken files followed by a successful run: every file after the abort
|
|
6548
|
+
// failed identically, got tallied as a coverage gap, and the missing
|
|
6549
|
+
// symbols became indistinguishable from files that genuinely have none.
|
|
6550
|
+
if (isWasmAbort(error))
|
|
6551
|
+
throw error;
|
|
6431
6552
|
console.error(`Error indexing file ${relativePath}:`, error);
|
|
6432
6553
|
if (unparsed)
|
|
6433
6554
|
tallyOne(unparsed, extensionBucket(relativePath));
|
|
@@ -7790,11 +7911,16 @@ export function getWatcherQueueStats(repoPath) {
|
|
|
7790
7911
|
generationAdvances: 0,
|
|
7791
7912
|
};
|
|
7792
7913
|
}
|
|
7793
|
-
/**
|
|
7794
|
-
|
|
7914
|
+
/**
|
|
7915
|
+
* Inject one queue failure for the R45 recovery regression test.
|
|
7916
|
+
*
|
|
7917
|
+
* `kind: "wasm-abort"` injects an Emscripten-shaped abort instead of an
|
|
7918
|
+
* ordinary error, which the flush must treat as terminal rather than retryable.
|
|
7919
|
+
*/
|
|
7920
|
+
export function failNextWatcherFlush(repoPath, kind = "error") {
|
|
7795
7921
|
const queue = watchQueues.get(path.resolve(repoPath));
|
|
7796
7922
|
if (queue)
|
|
7797
|
-
queue.failNextFlush =
|
|
7923
|
+
queue.failNextFlush = kind;
|
|
7798
7924
|
}
|
|
7799
7925
|
/** Hold one queue flush at a deterministic boundary for R45 backpressure tests. */
|
|
7800
7926
|
export function gateNextWatcherFlushForTests(repoPath, gate) {
|
|
@@ -8210,7 +8336,18 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
|
|
|
8210
8336
|
if (testGate)
|
|
8211
8337
|
await testGate;
|
|
8212
8338
|
if (queue.failNextFlush) {
|
|
8339
|
+
const injected = queue.failNextFlush;
|
|
8213
8340
|
queue.failNextFlush = false;
|
|
8341
|
+
if (injected === "wasm-abort") {
|
|
8342
|
+
// Deliberately NOT an Error: the real ExitStatus is a plain object,
|
|
8343
|
+
// and an Error-shaped stand-in would let a naive `instanceof Error`
|
|
8344
|
+
// check pass and hide the very bug this pins.
|
|
8345
|
+
throw Object.assign(Object.create(null), {
|
|
8346
|
+
name: "ExitStatus",
|
|
8347
|
+
message: "Program terminated with exit(1)",
|
|
8348
|
+
status: 1,
|
|
8349
|
+
});
|
|
8350
|
+
}
|
|
8214
8351
|
throw new Error("injected watcher flush failure");
|
|
8215
8352
|
}
|
|
8216
8353
|
for (const relativePath of paths) {
|
|
@@ -8238,6 +8375,16 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
|
|
|
8238
8375
|
catch (error) {
|
|
8239
8376
|
queue.errors++;
|
|
8240
8377
|
console.error(`Error flushing watcher updates for ${resolvedRepoPath}:`, error);
|
|
8378
|
+
if (isWasmAbort(error)) {
|
|
8379
|
+
// Unrecoverable and process-wide: re-queueing would retry the same
|
|
8380
|
+
// files every debounce interval, forever, each pass failing at the
|
|
8381
|
+
// first parse and logging again. Stop the watcher instead — the
|
|
8382
|
+
// graph is stale from here and only a fresh process can fix it.
|
|
8383
|
+
queue.closed = true;
|
|
8384
|
+
queue.pending.clear();
|
|
8385
|
+
watcherStates.set(resolvedRepoPath, { watcher: "disabled" });
|
|
8386
|
+
break;
|
|
8387
|
+
}
|
|
8241
8388
|
for (const relativePath of paths)
|
|
8242
8389
|
queue.pending.add(relativePath);
|
|
8243
8390
|
if (!queue.closed) {
|
|
@@ -8732,23 +8879,34 @@ export async function getOrInitDb(repoPath, options = {}) {
|
|
|
8732
8879
|
tree = parser.parse(source);
|
|
8733
8880
|
}
|
|
8734
8881
|
catch {
|
|
8882
|
+
parser.delete();
|
|
8735
8883
|
backfillComplete = false;
|
|
8736
8884
|
continue;
|
|
8737
8885
|
}
|
|
8738
|
-
if (!tree)
|
|
8886
|
+
if (!tree) {
|
|
8887
|
+
parser.delete();
|
|
8739
8888
|
continue;
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8889
|
+
}
|
|
8890
|
+
// This loop runs once per indexed file, so anything it fails to
|
|
8891
|
+
// free scales with repository size.
|
|
8892
|
+
try {
|
|
8893
|
+
db.run("DELETE FROM mcp_tools WHERE filePath = ?", [filePath]);
|
|
8894
|
+
for (const registration of extractMcpToolRegistrations(source, filePath, tree.rootNode)) {
|
|
8895
|
+
db.run("INSERT INTO mcp_tools(name, description, schemaSymbol, handlerSymbol, filePath, line, confidence, associationKey) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [
|
|
8896
|
+
registration.name,
|
|
8897
|
+
registration.description ?? null,
|
|
8898
|
+
registration.schemaSymbol ?? null,
|
|
8899
|
+
registration.handlerSymbol ?? null,
|
|
8900
|
+
filePath,
|
|
8901
|
+
registration.line,
|
|
8902
|
+
registration.confidence,
|
|
8903
|
+
registration.associationKey ?? null,
|
|
8904
|
+
]);
|
|
8905
|
+
}
|
|
8906
|
+
}
|
|
8907
|
+
finally {
|
|
8908
|
+
freeQuietly(tree);
|
|
8909
|
+
freeQuietly(parser);
|
|
8752
8910
|
}
|
|
8753
8911
|
}
|
|
8754
8912
|
catch {
|
|
@@ -11874,7 +12032,15 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
11874
12032
|
if (lang) {
|
|
11875
12033
|
const parser = new Parser();
|
|
11876
12034
|
parser.setLanguage(lang);
|
|
11877
|
-
|
|
12035
|
+
// The binding dies with this block, so nothing else could ever
|
|
12036
|
+
// free it — including when `parse` itself throws, which the
|
|
12037
|
+
// outer `catch` only logs. The tree stays valid without it.
|
|
12038
|
+
try {
|
|
12039
|
+
tree = parser.parse(source.text);
|
|
12040
|
+
}
|
|
12041
|
+
finally {
|
|
12042
|
+
freeQuietly(parser);
|
|
12043
|
+
}
|
|
11878
12044
|
}
|
|
11879
12045
|
}
|
|
11880
12046
|
}
|
|
@@ -11940,6 +12106,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
11940
12106
|
symbolRisks.push(risk);
|
|
11941
12107
|
}
|
|
11942
12108
|
}
|
|
12109
|
+
// Last use of this file's tree; the next iteration reassigns the
|
|
12110
|
+
// binding, so not freeing here loses it for the rest of the review.
|
|
12111
|
+
if (tree)
|
|
12112
|
+
freeQuietly(tree);
|
|
12113
|
+
tree = null;
|
|
11943
12114
|
}
|
|
11944
12115
|
}
|
|
11945
12116
|
finally {
|
|
@@ -15483,7 +15654,18 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15483
15654
|
return undefined;
|
|
15484
15655
|
const parser = new Parser();
|
|
15485
15656
|
parser.setLanguage(language);
|
|
15486
|
-
|
|
15657
|
+
// This runs once per traversal edge, not once per file, so the
|
|
15658
|
+
// same file can be reparsed many times within a single query.
|
|
15659
|
+
// Both objects are freed on every exit from this closure — the
|
|
15660
|
+
// parser here even when `parse` throws, which the outer `catch`
|
|
15661
|
+
// would otherwise swallow along with the allocation.
|
|
15662
|
+
let tree;
|
|
15663
|
+
try {
|
|
15664
|
+
tree = parser.parse(content);
|
|
15665
|
+
}
|
|
15666
|
+
finally {
|
|
15667
|
+
freeQuietly(parser);
|
|
15668
|
+
}
|
|
15487
15669
|
let matched;
|
|
15488
15670
|
const visit = (node) => {
|
|
15489
15671
|
if (matched ||
|
|
@@ -15502,17 +15684,26 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15502
15684
|
for (const child of node.namedChildren)
|
|
15503
15685
|
visit(child);
|
|
15504
15686
|
};
|
|
15505
|
-
|
|
15506
|
-
const argumentsNode = matched?.childForFieldName("arguments");
|
|
15507
|
-
if (!matched || !argumentsNode)
|
|
15687
|
+
if (!tree)
|
|
15508
15688
|
return undefined;
|
|
15509
|
-
|
|
15510
|
-
|
|
15511
|
-
|
|
15512
|
-
|
|
15513
|
-
|
|
15514
|
-
|
|
15515
|
-
|
|
15689
|
+
try {
|
|
15690
|
+
visit(tree.rootNode);
|
|
15691
|
+
const argumentsNode = matched?.childForFieldName("arguments");
|
|
15692
|
+
if (!matched || !argumentsNode)
|
|
15693
|
+
return undefined;
|
|
15694
|
+
return {
|
|
15695
|
+
kind: "call-arguments",
|
|
15696
|
+
// `.text` copies into JS strings, so the returned evidence
|
|
15697
|
+
// holds no reference into the tree being freed.
|
|
15698
|
+
arguments: argumentsNode.namedChildren.map((node) => node.text),
|
|
15699
|
+
evidence: matched.text.slice(0, 240),
|
|
15700
|
+
grounding: "tree-sitter",
|
|
15701
|
+
heuristic: true,
|
|
15702
|
+
};
|
|
15703
|
+
}
|
|
15704
|
+
finally {
|
|
15705
|
+
freeQuietly(tree);
|
|
15706
|
+
}
|
|
15516
15707
|
}
|
|
15517
15708
|
catch {
|
|
15518
15709
|
return undefined;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parentPort } from "node:worker_threads";
|
|
2
|
-
import { extractGenericFileForIndex } from "./index.js";
|
|
2
|
+
import { describeThrown, extractGenericFileForIndex, isWasmAbort } from "./index.js";
|
|
3
3
|
/**
|
|
4
4
|
* Parse-pool worker entry.
|
|
5
5
|
*
|
|
@@ -32,6 +32,10 @@ port.on("message", (task) => {
|
|
|
32
32
|
break;
|
|
33
33
|
default:
|
|
34
34
|
// `special` carries a live tree; hand the file back instead.
|
|
35
|
+
// Free it first — this branch is the tree's only owner, and the
|
|
36
|
+
// main thread reparses the file from scratch, so leaving it
|
|
37
|
+
// alive leaks wasm memory per file until the module aborts.
|
|
38
|
+
extraction.tree.delete();
|
|
35
39
|
response = { id: task.id, kind: "special" };
|
|
36
40
|
break;
|
|
37
41
|
}
|
|
@@ -40,11 +44,20 @@ port.on("message", (task) => {
|
|
|
40
44
|
// Serialize by hand. A thrown Error clones without its message on some
|
|
41
45
|
// paths, and losing the message turns a diagnosable failure into a
|
|
42
46
|
// mystery at exactly the moment the pool decides whether to retry.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
//
|
|
48
|
+
// `instanceof Error` is not enough on its own: Emscripten's `ExitStatus`
|
|
49
|
+
// is a plain object with `name`/`message`, so the naive ternary reports
|
|
50
|
+
// the wasm abort as "[object Object]". Shared with the CLI's top-level
|
|
51
|
+
// handler, which had the identical bug — one rule, one place.
|
|
52
|
+
response = { id: task.id, kind: "error", message: describeThrown(error) };
|
|
53
|
+
// The module is dead and does not come back: every later task in THIS
|
|
54
|
+
// worker would fail identically and fall back to the main thread, so the
|
|
55
|
+
// pool would quietly lose a lane for the rest of the run without any
|
|
56
|
+
// signal that it had. Report this file, then exit so the pool replaces
|
|
57
|
+
// the worker (the exit is deferred so the message actually flushes; if
|
|
58
|
+
// it does not, the pool's exit handler retries the file anyway).
|
|
59
|
+
if (isWasmAbort(error))
|
|
60
|
+
setImmediate(() => process.exit(1));
|
|
48
61
|
}
|
|
49
62
|
port.postMessage(response);
|
|
50
63
|
})();
|
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";
|
|
@@ -230,6 +230,63 @@ function fileSignature(filePath, allowOversized = false) {
|
|
|
230
230
|
throw error;
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* The configuration files a `configure` run actually changed, as repo-relative
|
|
235
|
+
* paths that indexing can accept.
|
|
236
|
+
*
|
|
237
|
+
* `configure` used to hand the indexer no file list at all, which means "the
|
|
238
|
+
* whole repository" — a whole-tree walk to account for a change to three
|
|
239
|
+
* dotfiles, and minutes of work on a large checkout. Narrowing it here keeps
|
|
240
|
+
* the cost a function of the configuration, not of the repository.
|
|
241
|
+
*
|
|
242
|
+
* Directories and anything outside the worktree are dropped: they are audited
|
|
243
|
+
* for change reporting but are not indexable files.
|
|
244
|
+
*
|
|
245
|
+
* So is anything the prune policy excludes. `.git/` and `.knodin/` are INSIDE
|
|
246
|
+
* the worktree in an ordinary checkout, so the `..` check below does not
|
|
247
|
+
* exclude them — it only happens to, in a linked worktree, where the git common
|
|
248
|
+
* dir lives elsewhere. Without this filter `configure` hands the indexer
|
|
249
|
+
* `.git/hooks/post-commit`, `.git/info/exclude` and `.knodin/integration.json`,
|
|
250
|
+
* which the explicit-file index path does not re-filter, so it records
|
|
251
|
+
* `index_state` rows for paths a full index would never visit and the next
|
|
252
|
+
* reconciliation then has to purge.
|
|
253
|
+
*/
|
|
254
|
+
function changedConfigurationFiles(repo, targets, before) {
|
|
255
|
+
const after = mutationSnapshot(targets, true);
|
|
256
|
+
const changed = [];
|
|
257
|
+
for (const target of targets) {
|
|
258
|
+
if (before.get(target.label) === after.get(target.label))
|
|
259
|
+
continue;
|
|
260
|
+
const relative = path.relative(repo, target.absolute);
|
|
261
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
262
|
+
continue;
|
|
263
|
+
// The SAME predicate a full index uses, not merely the prune check.
|
|
264
|
+
// `isIndexableSourcePath` is the canonical candidate policy shared by
|
|
265
|
+
// full-repository collection, the watcher, repair selection and hook
|
|
266
|
+
// refresh, so filtering through it makes this list a strict subset of what
|
|
267
|
+
// `init` would have indexed.
|
|
268
|
+
//
|
|
269
|
+
// Anything looser indexes files no other path in the system ever selects:
|
|
270
|
+
// `.mcp.json` and `.codex/config.toml` are configuration, not source, and
|
|
271
|
+
// including them writes `index_state` rows that the next reconciliation
|
|
272
|
+
// purges again — churn that looks like drift.
|
|
273
|
+
if (!isIndexableSourcePath(relative))
|
|
274
|
+
continue;
|
|
275
|
+
let stat;
|
|
276
|
+
try {
|
|
277
|
+
stat = fs.statSync(target.absolute);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Removed by this run. There is nothing to index, and pruning the
|
|
281
|
+
// deleted rows is the lifecycle refresh's job, not this list's.
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (!stat.isFile())
|
|
285
|
+
continue;
|
|
286
|
+
changed.push(relative);
|
|
287
|
+
}
|
|
288
|
+
return changed.sort(compareBytes);
|
|
289
|
+
}
|
|
233
290
|
function mutationSnapshot(targets, allowOversized = false) {
|
|
234
291
|
const snapshot = new Map();
|
|
235
292
|
for (const target of targets) {
|
|
@@ -357,10 +414,29 @@ export async function refreshFromGitEvent(repo, event, index) {
|
|
|
357
414
|
}
|
|
358
415
|
}
|
|
359
416
|
function backgroundScript(command) {
|
|
360
|
-
|
|
417
|
+
// The interpreter is resolved at RUN time, not baked in. `process.execPath`
|
|
418
|
+
// is symlink-resolved by Node, so a stable `/opt/homebrew/bin/node` becomes
|
|
419
|
+
// a version-pinned `/opt/homebrew/Cellar/node/<version>/bin/node`. Homebrew
|
|
420
|
+
// deletes that directory on upgrade, and every hook generated beforehand
|
|
421
|
+
// then dies with exit 127 — silently, because refresh runs in the
|
|
422
|
+
// background, so the graph just stops keeping up.
|
|
423
|
+
//
|
|
424
|
+
// The generated path stays PREFERRED, so nothing changes while it exists;
|
|
425
|
+
// the fallback only engages once it does not. Falling back first would
|
|
426
|
+
// silently switch which runtime executes, which is its own bug.
|
|
427
|
+
const [interpreter, ...rest] = command;
|
|
428
|
+
const invocation = ['"$KNODIN_NODE"', ...rest.map(shellQuote)].join(" ");
|
|
429
|
+
const preferred = shellQuote(interpreter ?? process.execPath);
|
|
361
430
|
return String.raw `#!/bin/sh
|
|
362
431
|
# knodin packaged background refresh. Generated by knodin init.
|
|
363
432
|
set -u
|
|
433
|
+
KNODIN_NODE=${preferred}
|
|
434
|
+
if [ ! -x "$KNODIN_NODE" ]; then
|
|
435
|
+
KNODIN_NODE="$(command -v node 2>/dev/null || true)"
|
|
436
|
+
# No interpreter at all: fall through to the recorded path so the failure is
|
|
437
|
+
# the existing loud "not found", not a silent no-op.
|
|
438
|
+
[ -n "$KNODIN_NODE" ] || KNODIN_NODE=${preferred}
|
|
439
|
+
fi
|
|
364
440
|
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
365
441
|
[ -n "$REPO_ROOT" ] || exit 0
|
|
366
442
|
cd "$REPO_ROOT" || exit 0
|
|
@@ -460,7 +536,7 @@ function refreshProcessorIsRunning(repo) {
|
|
|
460
536
|
return false;
|
|
461
537
|
}
|
|
462
538
|
}
|
|
463
|
-
function drainQueuedLifecycleEvents(repo, backgroundPath) {
|
|
539
|
+
function drainQueuedLifecycleEvents(repo, backgroundPath, leaseToken) {
|
|
464
540
|
const before = queuedLifecycleEvents(repo);
|
|
465
541
|
if (before.length === 0)
|
|
466
542
|
return { state: "fresh", queuedEvents: 0 };
|
|
@@ -469,6 +545,13 @@ function drainQueuedLifecycleEvents(repo, backgroundPath) {
|
|
|
469
545
|
encoding: "utf-8",
|
|
470
546
|
stdio: ["ignore", "pipe", "pipe"],
|
|
471
547
|
timeout: 30_000,
|
|
548
|
+
// The caller holds the lifecycle lease while this runs. Without handing
|
|
549
|
+
// the token down, the child re-enters knodin, meets its own parent's
|
|
550
|
+
// lease, and refuses — so the drain could never succeed and init
|
|
551
|
+
// reported failure on every run.
|
|
552
|
+
env: leaseToken
|
|
553
|
+
? { ...process.env, [LIFECYCLE_LEASE_TOKEN_ENV]: leaseToken }
|
|
554
|
+
: { ...process.env },
|
|
472
555
|
});
|
|
473
556
|
if (result.status !== 0) {
|
|
474
557
|
const detail = result.error?.message || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
|
|
@@ -1283,13 +1366,25 @@ export async function initializeRepository(repo, options) {
|
|
|
1283
1366
|
// Configuration is graph-visible, so explicit configuration must complete
|
|
1284
1367
|
// before the final index and health verification. Plain init reaches this
|
|
1285
1368
|
// point without touching agent instructions, MCP files, skills, or receipts.
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1369
|
+
//
|
|
1370
|
+
// "Graph-visible" justifies indexing the files configuration wrote. It does
|
|
1371
|
+
// not justify walking the whole repository to find them, which is what an
|
|
1372
|
+
// undefined file list means.
|
|
1373
|
+
const scopedIndexPaths = options.indexScope === "configuration"
|
|
1374
|
+
? changedConfigurationFiles(resolvedRepo, mutationTargets, beforeMutation)
|
|
1375
|
+
: undefined;
|
|
1376
|
+
if (!scopedIndexPaths || scopedIndexPaths.length > 0) {
|
|
1377
|
+
const indexResult = await options.index(resolvedRepo, { onProgress: options.onProgress }, scopedIndexPaths);
|
|
1378
|
+
// Only a full index has verified the whole graph, so only a full index
|
|
1379
|
+
// may fail the run on graph health. A configuration-scoped pass saw a
|
|
1380
|
+
// handful of files and knows nothing about the rest.
|
|
1381
|
+
if (!scopedIndexPaths &&
|
|
1382
|
+
isIndexResult(indexResult) &&
|
|
1383
|
+
indexResult.verification.status !== "healthy") {
|
|
1384
|
+
throw new InitializationHealthError(indexResult);
|
|
1385
|
+
}
|
|
1291
1386
|
}
|
|
1292
|
-
const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath);
|
|
1387
|
+
const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
|
|
1293
1388
|
await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), {
|
|
1294
1389
|
force: true,
|
|
1295
1390
|
});
|
|
@@ -1310,6 +1405,7 @@ export async function initializeRepository(repo, options) {
|
|
|
1310
1405
|
excluded,
|
|
1311
1406
|
lifecycleRefresh,
|
|
1312
1407
|
hookManagerIntegration,
|
|
1408
|
+
scopedIndexPaths,
|
|
1313
1409
|
};
|
|
1314
1410
|
}
|
|
1315
1411
|
finally {
|
|
@@ -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("
|
|
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("
|
|
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 });
|
package/dist/src/repair-lease.js
CHANGED
|
@@ -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);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# knodin 0.10.1
|
|
2
|
+
|
|
3
|
+
Three defects, none of which a test suite would have found on its own. Two were
|
|
4
|
+
found by upgrading knodin on a real machine: one only appears after Homebrew
|
|
5
|
+
moves the Node binary, the other only when a lifecycle event is already queued.
|
|
6
|
+
The third is a scheduling race that reported the same cause under two different
|
|
7
|
+
codes, and it surfaced as an unrelated pre-push failure after six clean runs.
|
|
8
|
+
|
|
9
|
+
## Background refresh survives a moved Node binary
|
|
10
|
+
|
|
11
|
+
`knodin init` wrote the generated hook with a fully-resolved path to whichever
|
|
12
|
+
Node was running at the time. `process.execPath` is symlink-resolved, so a
|
|
13
|
+
stable `/opt/homebrew/bin/node` became a version-pinned
|
|
14
|
+
`/opt/homebrew/Cellar/node/<version>/bin/node`.
|
|
15
|
+
|
|
16
|
+
Homebrew deletes that directory on upgrade, so every hook written beforehand
|
|
17
|
+
died with exit 127 — silently, because refresh runs in the background. The
|
|
18
|
+
graph simply stopped keeping up, and only a `knodin status` run revealed it.
|
|
19
|
+
It affected every repository initialized before the upgrade, not one.
|
|
20
|
+
|
|
21
|
+
The hook now resolves its interpreter when it runs. The recorded path stays
|
|
22
|
+
preferred, so nothing changes while it exists; `PATH` is consulted only once it
|
|
23
|
+
does not. Falling back first would silently change which runtime executes,
|
|
24
|
+
which is its own bug.
|
|
25
|
+
|
|
26
|
+
Hooks generated before this release still carry the old absolute path. They
|
|
27
|
+
keep failing until regenerated with `knodin init`; the fix prevents recurrence
|
|
28
|
+
after the next Node upgrade rather than repairing them retroactively.
|
|
29
|
+
|
|
30
|
+
## `knodin init` can drain its own lifecycle queue
|
|
31
|
+
|
|
32
|
+
`init` takes the lifecycle lease, then drains queued events by spawning the
|
|
33
|
+
background hook. The hook re-entered knodin, met the same lease, saw a live
|
|
34
|
+
pid — its own parent — and refused. `init` reported "queued lifecycle refresh
|
|
35
|
+
could not be drained (exit 1)" on every run, and `knodin status` then advised
|
|
36
|
+
running `init`, which reproduced the failure exactly.
|
|
37
|
+
|
|
38
|
+
Worse, the failed drain wrote a failure marker that `status` kept reporting, so
|
|
39
|
+
a transient condition looked permanent while the graph was in fact intact — in
|
|
40
|
+
the observed case 5,883 of 5,883 files indexed and 5,059 carrying symbols.
|
|
41
|
+
|
|
42
|
+
A lease holder now passes its nonce to processes it spawns, and a child whose
|
|
43
|
+
token matches the lease on disk is admitted re-entrantly. Re-entry returns a
|
|
44
|
+
no-op release, because releasing there would drop the lock while the parent
|
|
45
|
+
still believes it holds it.
|
|
46
|
+
|
|
47
|
+
The lease is not weakened. A missing token, or one that does not match the
|
|
48
|
+
lease on disk, still hits the existing contention check, so a genuinely
|
|
49
|
+
concurrent mutation from another process is refused exactly as before.
|
|
50
|
+
|
|
51
|
+
## One abort cause, one code
|
|
52
|
+
|
|
53
|
+
Worker startup and queue admission are each a race between "ready" and
|
|
54
|
+
"aborted". Each arm had an abort listener rejecting with
|
|
55
|
+
`KNODIN_CLIENT_DISCONNECTED` and a follow-up `signal.aborted` re-check
|
|
56
|
+
rejecting with `KNODIN_REQUEST_CANCELLED`, so the same client disconnect
|
|
57
|
+
surfaced as either code depending on which arm settled first.
|
|
58
|
+
|
|
59
|
+
That is worse than a consistently wrong code. It was stable on an idle machine
|
|
60
|
+
and unstable under load, so it looked trustworthy everywhere anyone would have
|
|
61
|
+
checked it — it surfaced as a pre-push gate failure on an unrelated change
|
|
62
|
+
after six clean runs.
|
|
63
|
+
|
|
64
|
+
It also fed the reliability journal, where the two codes map to distinct kinds.
|
|
65
|
+
One disconnect was recorded under two kinds, quietly skewing any rate computed
|
|
66
|
+
from them. **That contamination is retroactive and cannot be repaired:** journal
|
|
67
|
+
entries written before this release cannot be reattributed, so a
|
|
68
|
+
cancellation-versus-disconnect rate spanning the 0.10.1 boundary should be read
|
|
69
|
+
as suspect rather than corrected.
|
|
70
|
+
|
|
71
|
+
The taxonomy is unchanged. `superviseMcpRequest` can still tell an explicit
|
|
72
|
+
cancellation from a disconnect; the supervisor cannot, so it now names only the
|
|
73
|
+
cause it actually knows.
|
|
74
|
+
|
|
75
|
+
## Homebrew formula retention
|
|
76
|
+
|
|
77
|
+
The `Retain versioned Homebrew formula` job ran on a GitHub-hosted runner whose
|
|
78
|
+
ephemeral IP is not on the organization's allow list, so it failed at checkout
|
|
79
|
+
with a 403 on every release since it was added — and it cloned
|
|
80
|
+
`knodin/knodin`, a stale source mirror, rather than `knodin/homebrew-tap`.
|
|
81
|
+
|
|
82
|
+
Both are corrected. Every formula through 0.10.0 was retained by hand; this is
|
|
83
|
+
the first release where that job can do it.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# knodin 0.10.2
|
|
2
|
+
|
|
3
|
+
Two defects found by running 0.10.1 against a 902,960-file Salesforce checkout.
|
|
4
|
+
One of them means graphs built by earlier releases on large repositories are
|
|
5
|
+
incomplete in a way that nothing reported.
|
|
6
|
+
|
|
7
|
+
## Parsers were never freed, and the run reported success anyway
|
|
8
|
+
|
|
9
|
+
Indexing built a tree-sitter `Parser` for every file and freed neither it nor
|
|
10
|
+
the resulting syntax tree. web-tree-sitter objects live in the Emscripten heap
|
|
11
|
+
and are reclaimed only by an explicit `delete()`; of the five parse sites in the
|
|
12
|
+
engine, exactly one had it.
|
|
13
|
+
|
|
14
|
+
The heap therefore grew with every file until allocation failed and the wasm
|
|
15
|
+
module called `abort()`. Measured on a 219-byte XML file: leaking died at the
|
|
16
|
+
87,305th parse with `ExitStatus: Program terminated with exit(1)`, while freeing
|
|
17
|
+
survived 200,000 with no failure.
|
|
18
|
+
|
|
19
|
+
Three things went wrong from there, and the last is the reason this is a
|
|
20
|
+
correctness fix rather than a performance one.
|
|
21
|
+
|
|
22
|
+
The module never comes back. After the first abort, every later parse failed
|
|
23
|
+
identically — verified on freshly constructed parsers.
|
|
24
|
+
|
|
25
|
+
The error then named the wrong file. Every file processed after the abort was
|
|
26
|
+
reported as failing regardless of its contents, which is why the observed
|
|
27
|
+
failure list was a contiguous alphabetical run of Salesforce package manifests.
|
|
28
|
+
Those files are fine; all 144 of them parse cleanly on a shared parser.
|
|
29
|
+
|
|
30
|
+
And the failure was swallowed. The per-file handler logged, counted the file as
|
|
31
|
+
a coverage gap, and continued, so the run finished successfully. Files that were
|
|
32
|
+
never parsed became indistinguishable from files that genuinely contain no
|
|
33
|
+
symbols, and every later query answered confidently from a graph that was
|
|
34
|
+
quietly missing everything after the abort.
|
|
35
|
+
|
|
36
|
+
All five sites now free both objects on every path, including early returns and
|
|
37
|
+
error paths. A wasm abort is no longer treated as a per-file problem: it fails
|
|
38
|
+
the run, because it is not survivable and pretending otherwise is what turned
|
|
39
|
+
one real failure into thousands of misattributed ones.
|
|
40
|
+
|
|
41
|
+
**Reindex to trust an existing graph.** If a large repository was indexed with
|
|
42
|
+
an earlier release, symbol coverage may be short by an unknown amount and
|
|
43
|
+
nothing in `knodin status` would have said so. Smaller repositories that never
|
|
44
|
+
approached the threshold are unaffected.
|
|
45
|
+
|
|
46
|
+
The coverage line also stops claiming more than it knows. Files with no
|
|
47
|
+
tree-sitter grammar, minified sources, and files whose indexing failed were all
|
|
48
|
+
reported as `parsed but empty`, which asserts the parser looked and found
|
|
49
|
+
nothing — the one thing it did not do. They are now reported as `not parsed`.
|
|
50
|
+
|
|
51
|
+
## `knodin configure` no longer indexes the entire repository
|
|
52
|
+
|
|
53
|
+
`configure` refuses to run against an uninitialized repository on the grounds
|
|
54
|
+
that it "changes agent integration only", and then passed the indexer no file
|
|
55
|
+
list, which means every file in the tree. On a large checkout that is minutes of
|
|
56
|
+
work to account for a change to three dotfiles, with nothing in the output
|
|
57
|
+
saying an index was happening at all.
|
|
58
|
+
|
|
59
|
+
It now indexes only the files it actually wrote that a full index would also
|
|
60
|
+
have selected, and reports what it did instead of claiming the graph was
|
|
61
|
+
refreshed. That claim was most misleading precisely where it mattered: on a
|
|
62
|
+
repository whose graph was empty, `configure` silently rebuilt it from scratch,
|
|
63
|
+
which is `init`'s job and is what made a routine scope change look inexplicably
|
|
64
|
+
slow.
|
|
65
|
+
|
|
66
|
+
In practice `knodin configure --scope personal` now indexes nothing at all.
|
|
67
|
+
Personal scope writes only `.agents/`, `.codex/` and `.gemini/`, none of which
|
|
68
|
+
is source, so there is nothing for the indexer to do and it is not invoked.
|
|
69
|
+
Team scope still indexes the agent instructions it writes.
|
|
70
|
+
|
|
71
|
+
The filter is the same predicate a full index uses, rather than a looser one
|
|
72
|
+
written for this path. Anything looser records `index_state` rows for files no
|
|
73
|
+
other part of the system ever selects, which the next reconciliation then
|
|
74
|
+
purges — churn that reads as drift.
|
|
75
|
+
|
|
76
|
+
`knodin init` is unchanged and still indexes everything and verifies health.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knodin",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"knodin": {
|
|
5
5
|
"compatibility": "breaking"
|
|
6
6
|
},
|
|
@@ -72,6 +72,8 @@
|
|
|
72
72
|
"docs/releases/0.8.7.md",
|
|
73
73
|
"docs/releases/0.9.0.md",
|
|
74
74
|
"docs/releases/0.10.0.md",
|
|
75
|
+
"docs/releases/0.10.1.md",
|
|
76
|
+
"docs/releases/0.10.2.md",
|
|
75
77
|
"docs/assets/knodin-favicon.svg",
|
|
76
78
|
"docs/SYSTEMS-AND-RELATIONSHIPS.md",
|
|
77
79
|
"docs/TELEMETRY.md",
|