knodin 0.12.2 → 0.13.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 (45) hide show
  1. package/README.md +16 -1
  2. package/dist/bin/cli.js +173 -14
  3. package/dist/src/agent-events.js +25 -7
  4. package/dist/src/authenticated-cursor.js +81 -0
  5. package/dist/src/class-consumer-contract.js +18 -0
  6. package/dist/src/class-consumer-cursor.js +91 -0
  7. package/dist/src/class-consumer-delivery.js +22 -0
  8. package/dist/src/class-consumer-page.js +148 -0
  9. package/dist/src/cli-model.js +9 -3
  10. package/dist/src/docs-sections.js +1 -0
  11. package/dist/src/engine/apex-class-uses.js +430 -0
  12. package/dist/src/engine/apex-entry-points.js +98 -0
  13. package/dist/src/engine/apex-receiver.js +301 -0
  14. package/dist/src/engine/embedding-reuse.js +57 -0
  15. package/dist/src/engine/embeddings.js +22 -0
  16. package/dist/src/engine/index-coverage.js +215 -0
  17. package/dist/src/engine/index.js +2495 -252
  18. package/dist/src/engine/salesforce-components.js +460 -0
  19. package/dist/src/engine/seal.js +3 -0
  20. package/dist/src/engine/sqlite.js +44 -0
  21. package/dist/src/evidence-bundle.js +283 -0
  22. package/dist/src/evidence-graph.js +163 -0
  23. package/dist/src/failure-diagnosis.js +80 -5
  24. package/dist/src/file-dependency.js +35 -0
  25. package/dist/src/graph-query-health.js +47 -1
  26. package/dist/src/implementation-search.js +69 -0
  27. package/dist/src/index-coverage-read.js +33 -0
  28. package/dist/src/investigation.js +195 -0
  29. package/dist/src/mcp-reliability.js +4 -0
  30. package/dist/src/mcp-worker-supervisor.js +122 -6
  31. package/dist/src/progressive-evidence.js +4 -4
  32. package/dist/src/response-budget.js +129 -3
  33. package/dist/src/server.js +18 -5
  34. package/dist/src/shared-index/publisher.js +41 -1
  35. package/dist/src/tools/knodin-tools.js +224 -41
  36. package/docs/CLI.md +92 -0
  37. package/docs/MCP.md +67 -0
  38. package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
  39. package/docs/SALESFORCE-BINDINGS.md +121 -0
  40. package/docs/SALESFORCE-DEAD-CODE.md +45 -0
  41. package/docs/SCOPED-INDEXING.md +76 -0
  42. package/docs/apex-receiver-resolution.md +41 -0
  43. package/docs/releases/0.13.0.md +62 -0
  44. package/docs/structural-only-indexing.md +20 -0
  45. package/package.json +11 -5
@@ -1,3 +1,4 @@
1
+ import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "./index-coverage-read.js";
1
2
  function hasNonReconciliableDamage(graph) {
2
3
  if (Object.values(graph.orphaned).some((count) => count > 0))
3
4
  return true;
@@ -17,6 +18,14 @@ function unavailableState(graph) {
17
18
  return "indexing";
18
19
  if (graph.status === "indexing")
19
20
  return "indexing";
21
+ if (graph.indexCoverage?.origin === "invalid")
22
+ return "repair-needed";
23
+ if (graph.coverage.sourceFiles === 0 &&
24
+ graph.indexCoverage &&
25
+ (graph.indexCoverage.intentState !== "complete" || graph.indexCoverage.mode === "unknown"))
26
+ return "unknown";
27
+ if (graph.coverage.sourceFiles === 0 && graph.indexCoverage?.mode === "scoped")
28
+ return "empty-scope";
20
29
  if (graph.schemaVersion === 0)
21
30
  return graph.coverage.sourceFiles === 0 ? "empty-repository" : "not-initialized";
22
31
  if (graph.coverage.sourceFiles === 0)
@@ -44,6 +53,7 @@ export async function inspectGraphQueryHealth(repo, status) {
44
53
  status: "unavailable",
45
54
  state,
46
55
  graph,
56
+ ...(graph.indexCoverage ? { indexCoverage: graph.indexCoverage } : {}),
47
57
  remediation: graph.repairSteps,
48
58
  };
49
59
  }
@@ -72,9 +82,40 @@ function hasNoMatches(value) {
72
82
  return false;
73
83
  }
74
84
  /** Add an explicit healthy/no-match outcome without replacing operation data. */
75
- export function decorateGraphQueryResult(value, graphState = "healthy", freshness) {
85
+ export function decorateGraphQueryResult(value, graphState = "healthy", freshness, ...coverageObservations) {
86
+ const [indexCoverage, initialCoverage] = coverageObservations;
87
+ const ownCoverage = value && typeof value === "object" && !Array.isArray(value)
88
+ ? value.indexCoverage
89
+ : undefined;
90
+ const readCoverage = ownCoverage ?? initialCoverage;
91
+ // Omitted observations on legacy callers are not the same as an explicit
92
+ // post-read observation that lost coverage metadata. A result's own scope
93
+ // belongs to its read lease and supersedes an older preflight observation.
94
+ if (coverageObservations.length >= 1 &&
95
+ (ownCoverage !== undefined || coverageObservations.length >= 2) &&
96
+ indexCoverageSnapshot(readCoverage) !== indexCoverageSnapshot(indexCoverage))
97
+ return {
98
+ available: false,
99
+ status: "unavailable",
100
+ state: "changed-during-read",
101
+ indexCoverage,
102
+ indexCoverageAtRead: indexCoverageCore(readCoverage),
103
+ remediation: [
104
+ "Index coverage changed during the read; results were withheld. Retry against the current coverage.",
105
+ ],
106
+ };
107
+ const selectedCoverage = ownCoverage ?? indexCoverage;
108
+ const coverageFields = selectedCoverage
109
+ ? {
110
+ indexCoverage: selectedCoverage,
111
+ ...(indexCoverageNotice(selectedCoverage)
112
+ ? { coverageNotice: indexCoverageNotice(selectedCoverage) }
113
+ : {}),
114
+ }
115
+ : {};
76
116
  if (value === null || value === undefined) {
77
117
  return {
118
+ ...coverageFields,
78
119
  status: "no-match",
79
120
  result: value ?? null,
80
121
  ...(freshness ? { freshness } : {}),
@@ -83,6 +124,7 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
83
124
  }
84
125
  if (Array.isArray(value)) {
85
126
  return {
127
+ ...coverageFields,
86
128
  status: value.length === 0 ? "no-match" : "ok",
87
129
  results: value,
88
130
  ...(freshness ? { freshness } : {}),
@@ -95,12 +137,15 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
95
137
  }
96
138
  if (typeof value !== "object") {
97
139
  return {
140
+ ...coverageFields,
98
141
  result: value,
99
142
  ...(freshness ? { freshness } : {}),
100
143
  availability: { state: graphState, outcome: "complete" },
101
144
  };
102
145
  }
103
146
  const root = value;
147
+ if (root.available === false || root.status === "unavailable")
148
+ return { ...root, ...coverageFields };
104
149
  // An unresolved target is not a no-match: nothing was searched for. Calling
105
150
  // it `no-match` is what let a misspelled symbol read as proof that nothing
106
151
  // calls it (KNODIN-28). Ambiguity is exempted for the same reason.
@@ -109,6 +154,7 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
109
154
  const state = notFound ? "target-not-found" : noMatch ? "no-match" : graphState;
110
155
  return {
111
156
  ...root,
157
+ ...coverageFields,
112
158
  ...(freshness ? { freshness } : {}),
113
159
  ...(notFound && root.status === undefined ? { status: "target-not-found" } : {}),
114
160
  ...(noMatch && root.status === undefined ? { status: "no-match" } : {}),
@@ -0,0 +1,69 @@
1
+ /** An explicit discovery lens, never the default for general symbol search. */
2
+ export const IMPLEMENTATION_KINDS = ["function", "method", "class", "constructor"];
3
+ /** Rank possible implementations without choosing one or turning relevance into proof. */
4
+ export async function findImplementationCandidates(engine, repo, request) {
5
+ const task = request.task.trim();
6
+ if (!task)
7
+ throw new Error("Implementation discovery requires a task");
8
+ const limit = request.limit ?? 10;
9
+ const offset = request.offset ?? 0;
10
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50)
11
+ throw new Error("Implementation discovery limit must be an integer from 1 to 50");
12
+ if (!Number.isSafeInteger(offset) || offset < 0)
13
+ throw new Error("Implementation discovery offset must be a nonnegative integer");
14
+ const page = await engine.search(task, repo, limit, {
15
+ kinds: [...IMPLEMENTATION_KINDS],
16
+ federate: false,
17
+ includeSource: false,
18
+ offset,
19
+ });
20
+ const unresolved = [];
21
+ if (page.semanticReadiness !== "ready")
22
+ unresolved.push(`Semantic evidence is ${page.semanticReadiness}; implementations may be missing.`);
23
+ if (page.results.some((row) => row.staleness !== "fresh" && row.staleness !== "reconciled"))
24
+ unresolved.push("Some candidate evidence is stale or has unknown freshness.");
25
+ return {
26
+ mode: "implementation-discovery",
27
+ repoPath: repo,
28
+ task,
29
+ status: unresolved.length ? "partial" : page.results.length ? "candidates" : "no-candidates",
30
+ candidates: page.results.map((row) => ({
31
+ identity: row.identity,
32
+ symbol: row.symbol,
33
+ file: row.filePath,
34
+ kind: row.kind,
35
+ rankScore: row.rrfScore,
36
+ staleness: row.staleness,
37
+ followup: {
38
+ operation: "context",
39
+ repoPath: repo,
40
+ task,
41
+ symbol: row.symbol,
42
+ identity: row.identity,
43
+ file: row.filePath,
44
+ },
45
+ })),
46
+ offset: page.offset,
47
+ limit: page.limit,
48
+ hasMore: page.hasMore,
49
+ continuation: page.hasMore
50
+ ? {
51
+ operation: "context",
52
+ contextMode: "implementation",
53
+ repoPath: repo,
54
+ task,
55
+ offset: page.offset + page.results.length,
56
+ limit,
57
+ }
58
+ : null,
59
+ retrieval: page.retrieval,
60
+ semanticReadiness: page.semanticReadiness,
61
+ unresolved,
62
+ bounds: {
63
+ includedKinds: [...IMPLEMENTATION_KINDS],
64
+ localRepositoryOnly: true,
65
+ automaticSelection: false,
66
+ note: "Ranked candidates require inspection. Types, interfaces, constants and other kinds are intentionally excluded; use ordinary search to find them. No completeness or implementation-correctness claim.",
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,33 @@
1
+ /** Bounded, semantic coverage state. Previews are presentation, not snapshot identity. */
2
+ export function indexCoverageCore(coverage) {
3
+ if (!coverage)
4
+ return undefined;
5
+ const { version, mode, revision, digest, origin, prefixCount, fileCount, intentState, repositoryComplete, negativeScope, } = coverage;
6
+ return {
7
+ version,
8
+ mode,
9
+ revision,
10
+ digest,
11
+ origin,
12
+ prefixCount,
13
+ fileCount,
14
+ intentState,
15
+ repositoryComplete,
16
+ negativeScope,
17
+ };
18
+ }
19
+ export function indexCoverageSnapshot(coverage) {
20
+ return JSON.stringify(indexCoverageCore(coverage) ?? null);
21
+ }
22
+ /** Inventory coverage cannot certify complete static relationships or runtime nonuse. */
23
+ export function indexCoverageNotice(coverage) {
24
+ if (!coverage)
25
+ return undefined;
26
+ if (coverage.intentState !== "complete" || coverage.negativeScope === "unknown")
27
+ return "Index coverage completeness is unverified, including within any declared scope; a negative graph answer does not establish absence.";
28
+ if (coverage.mode === "scoped")
29
+ return "Indexed graph evidence is limited to the declared index scope; repository-wide completeness is not established. This does not describe separate source, language-server or federated coverage.";
30
+ if (coverage.mode === "unknown" || !coverage.repositoryComplete)
31
+ return "Index coverage completeness is unverified; a negative answer does not establish repository-wide absence.";
32
+ return undefined;
33
+ }
@@ -0,0 +1,195 @@
1
+ import { compareBytes } from "./compare.js";
2
+ import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "./index-coverage-read.js";
3
+ function snapshot(state) {
4
+ return JSON.stringify([
5
+ state.indexGeneration,
6
+ state.freshness?.currentHead,
7
+ state.freshness?.indexedHead,
8
+ state.freshness?.workingTree?.indexedFingerprint,
9
+ indexCoverageSnapshot(state.indexCoverage),
10
+ ]);
11
+ }
12
+ /** A bounded investigation over existing facts; never an assertion of runtime completeness. */
13
+ export async function buildTaskInvestigation(engine, repo, request) {
14
+ const { symbol, task } = request;
15
+ if (!symbol.trim() || !task.trim())
16
+ throw new Error("An investigation requires task and symbol");
17
+ const limit = request.limit ?? 20;
18
+ const depth = request.depth ?? 3;
19
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
20
+ throw new Error("Investigation limit must be an integer from 1 to 100");
21
+ if (!Number.isInteger(depth) || depth < 1 || depth > 6)
22
+ throw new Error("Investigation depth must be an integer from 1 to 6");
23
+ const selector = {
24
+ identity: request.identity,
25
+ file: request.file,
26
+ kind: request.kind,
27
+ };
28
+ // Establish the read lease before snapshotting: a cold disk graph can
29
+ // reconcile on its first query without any concurrent source mutation.
30
+ await engine.query("file_summary", request.file ?? "", repo, undefined, 1);
31
+ const before = await engine.status(repo);
32
+ const beforeSnapshot = snapshot(before);
33
+ const coverageAtRead = indexCoverageSnapshot(before.indexCoverage);
34
+ const explanation = await engine.explain(symbol, repo, "minimal", selector);
35
+ // Older adapters may omit per-operation coverage. An explicit observation
36
+ // must agree with the captured lease, even if status later changes back.
37
+ const explanationCoverageChanged = explanation.indexCoverage !== undefined &&
38
+ indexCoverageSnapshot(explanation.indexCoverage) !== coverageAtRead;
39
+ const base = {
40
+ mode: "investigation",
41
+ repoPath: repo,
42
+ task,
43
+ symbol: explanation.symbol,
44
+ ...(before.indexCoverage ? { indexCoverage: indexCoverageCore(before.indexCoverage) } : {}),
45
+ ...(indexCoverageNotice(before.indexCoverage)
46
+ ? { coverageNotice: indexCoverageNotice(before.indexCoverage) }
47
+ : {}),
48
+ };
49
+ const changed = (after) => ({
50
+ ...base,
51
+ available: false,
52
+ indexCoverage: indexCoverageCore(after.indexCoverage),
53
+ coverageNotice: indexCoverageNotice(after.indexCoverage),
54
+ status: "changed-during-investigation",
55
+ targetResolution: "unverified",
56
+ unresolved: [
57
+ "Graph generation or index coverage changed during investigation; mixed evidence was withheld. Retry against the current source and scope.",
58
+ ],
59
+ continuation: { operation: "context", repoPath: repo, task, symbol, ...selector, limit, depth },
60
+ });
61
+ if (explanation.ambiguity || !explanation.identity || explanationCoverageChanged) {
62
+ const after = await engine.status(repo);
63
+ if (explanationCoverageChanged || beforeSnapshot !== snapshot(after))
64
+ return changed(after);
65
+ }
66
+ if (explanation.ambiguity)
67
+ return {
68
+ ...base,
69
+ status: "ambiguous",
70
+ ambiguity: explanation.ambiguity,
71
+ next: "Select one identity or definition file before investigating dependencies.",
72
+ };
73
+ if (!explanation.identity)
74
+ return {
75
+ ...base,
76
+ status: "not-found",
77
+ targetResolution: "not-found",
78
+ next: "Locate the definition with search, then supply its symbol and file or identity.",
79
+ };
80
+ const resolved = { ...selector, identity: explanation.identity };
81
+ const query = (pattern, target = explanation.symbol, selected = resolved) => engine.query(pattern, target, repo, undefined, limit, depth, "minimal", selected, pattern === "impact" ? { direction: "upstream", includeTests: true } : undefined);
82
+ const [sites, callers, impact, tests] = await Promise.all([
83
+ query("rename_preview"),
84
+ query("callers_of"),
85
+ query("impact"),
86
+ query("tests_for"),
87
+ ]);
88
+ const definition = sites.results.find((row) => row.kind === "definition" && row.identity === explanation.identity);
89
+ const importers = definition?.file ? await query("importers_of", definition.file, {}) : undefined;
90
+ const after = await engine.status(repo);
91
+ const observationCoverageChanged = [sites, callers, impact, tests, importers].some((result) => result?.indexCoverage !== undefined &&
92
+ indexCoverageSnapshot(result.indexCoverage) !== coverageAtRead);
93
+ if (observationCoverageChanged || beforeSnapshot !== snapshot(after))
94
+ return changed(after);
95
+ const unresolved = [];
96
+ const coverageNotice = indexCoverageNotice(after.indexCoverage);
97
+ if (coverageNotice)
98
+ unresolved.push(coverageNotice);
99
+ if (after.freshness?.state !== "fresh")
100
+ unresolved.push(`Graph freshness is ${after.freshness?.state ?? "unknown"}.`);
101
+ if (!definition)
102
+ unresolved.push("Definition location was not returned within the site limit; file importers were not checked.");
103
+ if (explanation.sourceAvailability && explanation.sourceAvailability.state !== "available")
104
+ unresolved.push(`Source availability: ${explanation.sourceAvailability.state}`);
105
+ if (explanation.omissionCount)
106
+ unresolved.push(`${explanation.omissionCount} static-analysis omissions reported for the target; inspect explain for details.`);
107
+ const observations = [
108
+ ["sites", sites],
109
+ ["callers", callers],
110
+ ["impact", impact],
111
+ ["tests", tests],
112
+ ["importers", importers],
113
+ ];
114
+ for (const [name, result] of observations) {
115
+ if (!result)
116
+ continue;
117
+ if (result.staleness !== "fresh")
118
+ unresolved.push(`${name} evidence freshness is ${result.staleness ?? "unknown"}.`);
119
+ if (result.hasMore || result.count > result.results.length)
120
+ unresolved.push(`${name} results exceed the item limit; replay with a larger limit or inspect that relationship separately.`);
121
+ if (result.available === false || result.ambiguity || result.targetResolution === "not-found")
122
+ unresolved.push(`${name} could not establish the requested evidence.`);
123
+ }
124
+ if (explanation.staleness !== "fresh")
125
+ unresolved.push(`Target evidence freshness is ${explanation.staleness ?? "unknown"}.`);
126
+ const section = (result) => result && {
127
+ count: result.count,
128
+ returned: result.results.length,
129
+ truncated: result.hasMore === true || result.count > result.results.length,
130
+ results: result.results,
131
+ staleness: result.staleness,
132
+ continuation: limit < 100
133
+ ? {
134
+ operation: "context",
135
+ repoPath: repo,
136
+ task,
137
+ symbol: explanation.symbol,
138
+ ...resolved,
139
+ limit: Math.min(limit * 2, 100),
140
+ depth,
141
+ }
142
+ : null,
143
+ next: limit < 100
144
+ ? "Replay the investigation with a larger per-query limit; this is not an offset page."
145
+ : `Investigation limit reached. Inspect ${result.pattern} separately with an explicit larger budget; no completeness claim is made.`,
146
+ };
147
+ const source = explanation.source ?? "";
148
+ const sourceLines = source.split("\n");
149
+ let selectedSource = "";
150
+ for (const line of sourceLines) {
151
+ const next = selectedSource ? `${selectedSource}\n${line}` : line;
152
+ if (Buffer.byteLength(next, "utf8") > 4096)
153
+ break;
154
+ selectedSource = next;
155
+ }
156
+ const sourceTruncated = selectedSource !== source;
157
+ if (sourceTruncated)
158
+ unresolved.push("Target source exceeds the investigation excerpt; use explain with source detail.");
159
+ return {
160
+ ...base,
161
+ status: unresolved.length ? "partial" : "evidence-ready",
162
+ targetResolution: "resolved",
163
+ target: {
164
+ identity: explanation.identity,
165
+ file: definition?.file,
166
+ line: definition?.line,
167
+ summary: explanation.summary,
168
+ source: selectedSource,
169
+ sourceTruncated,
170
+ continuation: {
171
+ operation: "explain",
172
+ repoPath: repo,
173
+ symbol: explanation.symbol,
174
+ ...resolved,
175
+ includeSource: true,
176
+ },
177
+ },
178
+ sites: section(sites),
179
+ callers: section(callers),
180
+ impact: section(impact),
181
+ tests: section(tests),
182
+ importers: section(importers),
183
+ filesToInspect: [
184
+ ...new Set(observations.flatMap(([, result]) => result?.results.flatMap((row) => (row.file ? [row.file] : [])) ?? [])),
185
+ ].sort(compareBytes),
186
+ unresolved,
187
+ bounds: {
188
+ depth,
189
+ itemsPerQuery: limit,
190
+ staticAnalysis: true,
191
+ runtimeCompleteness: false,
192
+ note: "Returned sites and tests are evidence to inspect, not a complete edit plan or proof of test coverage.",
193
+ },
194
+ };
195
+ }
@@ -103,6 +103,10 @@ export function classifyMcpFailure(error) {
103
103
  return { kind: "client-disconnected", code: "KNODIN_CLIENT_DISCONNECTED" };
104
104
  if (code === "KNODIN_DEADLINE_EXCEEDED")
105
105
  return { kind: "deadline-exceeded", code };
106
+ // Admission failed before dispatch, so preserve this code for safe retry even
107
+ // for operations that would otherwise mutate repository state.
108
+ if (code === "KNODIN_WORKER_CAPACITY")
109
+ return { kind: "worker-capacity", code };
106
110
  if (code === "SQLITE_BUSY" ||
107
111
  code === "SQLITE_LOCKED" ||
108
112
  /database is locked|graph lock/i.test(message))
@@ -21,16 +21,21 @@ function defaultWorkerCommand() {
21
21
  export class RepositoryWorker extends EventEmitter {
22
22
  repoPath;
23
23
  workerCommand;
24
+ admission;
24
25
  child;
25
26
  ready;
26
27
  pending = new Map();
27
28
  tail = Promise.resolve();
28
29
  restartTimes = [];
29
30
  lastFailureTraceId;
30
- constructor(repoPath, workerCommand = defaultWorkerCommand()) {
31
+ requests = 0;
32
+ closed = false;
33
+ closing;
34
+ constructor(repoPath, workerCommand = defaultWorkerCommand(), admission = Promise.resolve()) {
31
35
  super();
32
36
  this.repoPath = repoPath;
33
37
  this.workerCommand = workerCommand;
38
+ this.admission = admission;
34
39
  }
35
40
  start() {
36
41
  if (this.child && this.ready)
@@ -54,6 +59,8 @@ export class RepositoryWorker extends EventEmitter {
54
59
  // exactly the amount raised, which surfaces as the next request
55
60
  // burning its whole deadline in startup rather than as slowness here.
56
61
  const startup = setTimeout(() => child.kill("SIGKILL"), 10_000);
62
+ child.once("exit", () => clearTimeout(startup));
63
+ child.once("error", () => clearTimeout(startup));
57
64
  if (!child.stdout)
58
65
  throw codedError("KNODIN_WORKER_CRASHED", "graph worker stdout unavailable");
59
66
  const lines = readline.createInterface({ input: child.stdout });
@@ -167,8 +174,47 @@ export class RepositoryWorker extends EventEmitter {
167
174
  }, CANCEL_GRACE_MS);
168
175
  }
169
176
  async execute(context, args, signal, onProgress) {
177
+ if (this.closed)
178
+ throw codedError("KNODIN_WORKER_CLOSED", "graph worker was retired; acquire it again");
179
+ this.requests++;
180
+ this.emit("busy");
181
+ const deadlineAt = Date.now() + operationDeadlineMs(context.operation);
182
+ try {
183
+ let admissionTimer;
184
+ let admissionAbort;
185
+ try {
186
+ await Promise.race([
187
+ this.admission,
188
+ new Promise((_resolve, reject) => {
189
+ admissionTimer = setTimeout(() => reject(codedError("KNODIN_DEADLINE_EXCEEDED", "request expired while waiting for worker admission")), Math.max(1, deadlineAt - Date.now()));
190
+ admissionAbort = () => reject(codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker admission"));
191
+ signal.addEventListener("abort", admissionAbort, { once: true });
192
+ if (signal.aborted)
193
+ admissionAbort();
194
+ }),
195
+ ]);
196
+ }
197
+ finally {
198
+ if (admissionTimer)
199
+ clearTimeout(admissionTimer);
200
+ if (admissionAbort)
201
+ signal.removeEventListener("abort", admissionAbort);
202
+ }
203
+ if (signal.aborted)
204
+ throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker admission");
205
+ return await this.executeRequest(context, args, signal, onProgress, deadlineAt);
206
+ }
207
+ finally {
208
+ this.requests--;
209
+ if (!this.requests)
210
+ this.emit("idle");
211
+ }
212
+ }
213
+ get busy() {
214
+ return this.requests > 0;
215
+ }
216
+ async executeRequest(context, args, signal, onProgress, deadlineAt = Date.now() + operationDeadlineMs(context.operation)) {
170
217
  const deadlineMs = operationDeadlineMs(context.operation);
171
- const deadlineAt = Date.now() + deadlineMs;
172
218
  const predecessor = this.tail;
173
219
  let release;
174
220
  this.tail = new Promise((resolve) => {
@@ -181,7 +227,7 @@ export class RepositoryWorker extends EventEmitter {
181
227
  await Promise.race([
182
228
  predecessor,
183
229
  new Promise((_resolve, reject) => {
184
- queueTimer = setTimeout(() => reject(codedError("KNODIN_DEADLINE_EXCEEDED", `${context.operation} exceeded its ${deadlineMs}ms deadline while queued`)), deadlineMs);
230
+ queueTimer = setTimeout(() => reject(codedError("KNODIN_DEADLINE_EXCEEDED", `${context.operation} exceeded its ${deadlineMs}ms deadline while queued`)), Math.max(1, deadlineAt - Date.now()));
185
231
  queueAbort = () => reject(codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected while queued"));
186
232
  signal.addEventListener("abort", queueAbort, { once: true });
187
233
  }),
@@ -200,6 +246,10 @@ export class RepositoryWorker extends EventEmitter {
200
246
  }
201
247
  const { requestId, operation } = context;
202
248
  try {
249
+ if (this.closed)
250
+ throw codedError("KNODIN_WORKER_CLOSED", "graph worker is closed");
251
+ if (Date.now() >= deadlineAt)
252
+ throw codedError("KNODIN_DEADLINE_EXCEEDED", "request expired before worker dispatch");
203
253
  // Same race as the startup one below, one phase earlier: the queueAbort
204
254
  // arm above reports CLIENT_DISCONNECTED, and this re-check reported
205
255
  // CANCELLED for the identical event whenever the queue wait resolved in
@@ -267,6 +317,8 @@ export class RepositoryWorker extends EventEmitter {
267
317
  if (signal.aborted)
268
318
  throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected during worker startup");
269
319
  const predecessorTraceId = this.lastFailureTraceId;
320
+ if (this.closed)
321
+ throw codedError("KNODIN_WORKER_CLOSED", "graph worker is closed");
270
322
  if (predecessorTraceId)
271
323
  recordMcpLifecycle(context, "restart", { predecessorTraceId });
272
324
  const result = await new Promise((resolve, reject) => {
@@ -317,6 +369,12 @@ export class RepositoryWorker extends EventEmitter {
317
369
  this.child?.kill("SIGKILL");
318
370
  }
319
371
  async close() {
372
+ this.closed = true;
373
+ this.closing ??= this.closeChild();
374
+ return this.closing;
375
+ }
376
+ async closeChild() {
377
+ await this.admission;
320
378
  const child = this.child;
321
379
  if (child?.exitCode !== null)
322
380
  return;
@@ -348,12 +406,23 @@ export class RepositoryWorker extends EventEmitter {
348
406
  }
349
407
  }
350
408
  export class WorkerSupervisor {
409
+ options;
351
410
  workers = new Map();
411
+ idleTimers = new Map();
412
+ retiring = new Set();
413
+ closed = false;
414
+ maxWorkers;
415
+ idleTimeoutMs;
352
416
  exitHandler = () => void this.close();
353
- constructor() {
417
+ constructor(options = {}) {
418
+ this.options = options;
419
+ this.maxWorkers = workerLimit(options.maxWorkers ?? Number(process.env.KNODIN_MCP_MAX_WORKERS ?? 4), "maxWorkers");
420
+ this.idleTimeoutMs = workerLimit(options.idleTimeoutMs ?? Number(process.env.KNODIN_MCP_IDLE_TIMEOUT_MS ?? 300_000), "idleTimeoutMs");
354
421
  process.once("exit", this.exitHandler);
355
422
  }
356
423
  worker(repoPath) {
424
+ if (this.closed)
425
+ throw codedError("KNODIN_WORKER_CLOSED", "worker supervisor is closed");
357
426
  const resolved = fs.realpathSync(path.resolve(repoPath));
358
427
  let key = resolved;
359
428
  try {
@@ -368,14 +437,61 @@ export class WorkerSupervisor {
368
437
  }
369
438
  let worker = this.workers.get(key);
370
439
  if (!worker) {
371
- worker = new RepositoryWorker(key);
440
+ if (this.workers.size >= this.maxWorkers) {
441
+ const victim = [...this.workers].find(([, candidate]) => !candidate.busy);
442
+ if (!victim)
443
+ throw codedError("KNODIN_WORKER_CAPACITY", `all ${this.maxWorkers} repository workers are busy; retry after an active request completes`);
444
+ this.retire(victim[0], victim[1]);
445
+ }
446
+ worker = new RepositoryWorker(key, this.options.workerCommand, Promise.all([...this.retiring]).then(() => undefined));
372
447
  this.workers.set(key, worker);
448
+ const instance = worker;
449
+ worker.on("busy", () => this.clearIdleTimer(key));
450
+ worker.on("idle", () => this.touch(key, instance));
373
451
  }
452
+ this.touch(key, worker);
374
453
  return worker;
375
454
  }
455
+ clearIdleTimer(key) {
456
+ clearTimeout(this.idleTimers.get(key));
457
+ this.idleTimers.delete(key);
458
+ }
459
+ touch(key, worker) {
460
+ if (this.closed || this.workers.get(key) !== worker)
461
+ return;
462
+ this.clearIdleTimer(key);
463
+ this.workers.delete(key);
464
+ this.workers.set(key, worker);
465
+ if (!worker.busy) {
466
+ const timer = setTimeout(() => {
467
+ if (!worker.busy && this.workers.get(key) === worker)
468
+ this.retire(key, worker);
469
+ }, this.idleTimeoutMs);
470
+ timer.unref();
471
+ this.idleTimers.set(key, timer);
472
+ }
473
+ }
474
+ retire(key, worker) {
475
+ this.clearIdleTimer(key);
476
+ this.workers.delete(key);
477
+ const closing = worker.close();
478
+ this.retiring.add(closing);
479
+ void closing.finally(() => this.retiring.delete(closing));
480
+ }
376
481
  async close() {
377
- await Promise.all([...this.workers.values()].map((worker) => worker.close()));
482
+ this.closed = true;
483
+ for (const key of this.idleTimers.keys())
484
+ this.clearIdleTimer(key);
485
+ await Promise.all([
486
+ ...this.retiring,
487
+ ...[...this.workers.values()].map((worker) => worker.close()),
488
+ ]);
378
489
  this.workers.clear();
379
490
  process.off("exit", this.exitHandler);
380
491
  }
381
492
  }
493
+ function workerLimit(value, name) {
494
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647)
495
+ throw new RangeError(`${name} must be a positive integer at most 2147483647`);
496
+ return value;
497
+ }
@@ -11,7 +11,7 @@ const MIN_PROGRESSIVE_BYTES = 1_600;
11
11
  function sha256(value) {
12
12
  return createHash("sha256").update(value).digest("hex");
13
13
  }
14
- function normalizeFile(repo, file) {
14
+ export function normalizeEvidenceFile(repo, file) {
15
15
  if (!file || path.isAbsolute(file))
16
16
  throw new Error("file must be a repository-relative path");
17
17
  const root = fs.realpathSync(repo);
@@ -38,7 +38,7 @@ function normalizeFile(repo, file) {
38
38
  throw new Error("progressive evidence requires a regular repository file");
39
39
  return { absolute, relative, root };
40
40
  }
41
- function handleSecret(root) {
41
+ export function evidenceHandleSecret(root) {
42
42
  const stateDirectory = resolveStateDir(root);
43
43
  if (fs.existsSync(stateDirectory) && fs.lstatSync(stateDirectory).isSymbolicLink())
44
44
  throw new Error("progressive evidence refuses a symlinked .knodin directory");
@@ -201,8 +201,8 @@ function omissionPath(request, handle, currentHash, contentBytes, relative) {
201
201
  export function deliverProgressiveEvidence(request) {
202
202
  if (!LEVELS.has(request.level))
203
203
  throw new Error("invalid progressive evidence level");
204
- const { absolute, relative, root } = normalizeFile(request.repo, request.file);
205
- const handleKey = handleSecret(root);
204
+ const { absolute, relative, root } = normalizeEvidenceFile(request.repo, request.file);
205
+ const handleKey = evidenceHandleSecret(root);
206
206
  const fileStat = fs.statSync(absolute);
207
207
  const keyStat = fs.statSync(handleKey.path);
208
208
  if (fileStat.dev === keyStat.dev && fileStat.ino === keyStat.ino)