knodin 0.7.5 → 0.8.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/README.md +18 -3
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +371 -66
- package/dist/bin/launcher.js +16 -1
- package/dist/src/agent-integration.js +82 -16
- package/dist/src/artifact-refresh.js +2 -1
- package/dist/src/cli-args.js +19 -1
- package/dist/src/cli-model.js +28 -2
- package/dist/src/codeflow-replay.js +2 -1
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-constraints.js +2 -1
- package/dist/src/competitive-runner.js +4 -4
- package/dist/src/context-export.js +3 -2
- package/dist/src/context.js +1 -1
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1160 -133
- package/dist/src/doctor.js +3 -1
- package/dist/src/engine/ann-hnsw.js +2 -12
- package/dist/src/engine/file-walker.js +8 -2
- package/dist/src/engine/git-history.js +12 -12
- package/dist/src/engine/index.js +1174 -313
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +28 -13
- package/dist/src/engine/source-policy.js +16 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/execution-profile.js +15 -10
- package/dist/src/failure-diagnosis.js +7 -1
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/index-activity.js +2 -1
- package/dist/src/init.js +86 -45
- package/dist/src/lifecycle-health.js +41 -9
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +2 -1
- package/dist/src/output-telemetry.js +16 -11
- package/dist/src/progressive-evidence.js +30 -26
- package/dist/src/pure-compression-cli.js +4 -3
- package/dist/src/relationship-adapters.js +15 -8
- package/dist/src/release-preflight.js +13 -10
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repository-init-process.js +13 -9
- package/dist/src/repository-management.js +34 -4
- package/dist/src/response-budget.js +8 -6
- package/dist/src/server.js +80 -35
- package/dist/src/structural-fast-path.js +16 -10
- package/dist/src/structural-snapshot.js +6 -2
- package/dist/src/system-config.js +25 -2
- package/dist/src/tools/knodin-tools.js +142 -31
- package/dist/src/update-ceremony.js +9 -5
- package/dist/src/update-trust.js +5 -4
- package/dist/src/visualization.js +372 -19
- package/dist/src/worktree-lifecycle.js +5 -2
- package/docs/BEHAVIORAL-CONTRACT.md +72 -0
- package/docs/CLI.md +20 -1
- package/docs/COMPARISON.md +403 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/DIAGNOSTICS.md +46 -11
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -2
- package/docs/MCP.md +59 -8
- package/docs/PT-ACCESS-RECOMMENDATION.md +5 -7
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +5 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/releases/0.5.1.md +4 -4
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/package.json +17 -4
- package/roadmap/competitive-roadmap.md +3801 -0
- package/schemas/release-attestation-v1.schema.json +1 -1
- package/schemas/support-bundle-v2.schema.json +212 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
import { resolveDbPath } from "./engine/state-paths.js";
|
|
4
|
+
import { attachMcpRequestSignal, handleKnodinTool } from "./tools/knodin-tools.js";
|
|
5
|
+
const IPC_VERSION = 1;
|
|
6
|
+
const active = new Map();
|
|
7
|
+
function send(value) {
|
|
8
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
9
|
+
}
|
|
10
|
+
async function receive(message) {
|
|
11
|
+
if (message.version !== IPC_VERSION)
|
|
12
|
+
return;
|
|
13
|
+
if (message.type === "cancel") {
|
|
14
|
+
active.get(message.id)?.abort("gateway-cancelled");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
active.set(message.id, controller);
|
|
19
|
+
send({ version: IPC_VERSION, type: "progress", id: message.id, sequence: 1, phase: "dispatch" });
|
|
20
|
+
try {
|
|
21
|
+
const fault = message.args?.__c89Fault;
|
|
22
|
+
if (process.env.KNODIN_MCP_FAULT_FIXTURES === "1") {
|
|
23
|
+
if (fault === "crash")
|
|
24
|
+
process.exit(91);
|
|
25
|
+
if (fault === "hang")
|
|
26
|
+
await new Promise(() => undefined);
|
|
27
|
+
if (fault === "lock") {
|
|
28
|
+
const repo = message.args.repoPath ?? process.cwd();
|
|
29
|
+
const database = new DatabaseSync(resolveDbPath(repo));
|
|
30
|
+
database.exec("INSERT INTO c89_lock_fixture(value) VALUES ('worker')");
|
|
31
|
+
database.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
attachMcpRequestSignal(message.args, controller.signal);
|
|
35
|
+
const result = await handleKnodinTool(message.args);
|
|
36
|
+
send({ version: IPC_VERSION, type: "result", id: message.id, result });
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
send({
|
|
40
|
+
version: IPC_VERSION,
|
|
41
|
+
type: "error",
|
|
42
|
+
id: message.id,
|
|
43
|
+
error: {
|
|
44
|
+
name: error instanceof Error ? error.name : "Error",
|
|
45
|
+
message: error instanceof Error ? error.message : String(error),
|
|
46
|
+
code: typeof error?.code === "string"
|
|
47
|
+
? error.code
|
|
48
|
+
: null,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
active.delete(message.id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const lines = readline.createInterface({
|
|
57
|
+
input: process.stdin,
|
|
58
|
+
crlfDelay: Number.POSITIVE_INFINITY,
|
|
59
|
+
});
|
|
60
|
+
lines.on("line", (line) => {
|
|
61
|
+
try {
|
|
62
|
+
void receive(JSON.parse(line));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
send({ version: IPC_VERSION, type: "protocol-error" });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
lines.on("close", () => process.exit(0));
|
|
69
|
+
send({ version: IPC_VERSION, type: "ready", pid: process.pid });
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import v8 from "node:v8";
|
|
5
|
+
import { repositoryBoundWrite } from "./diagnostics.js";
|
|
6
|
+
const LOG_PATH = ".knodin/mcp-reliability.jsonl";
|
|
7
|
+
const MAX_LOG_BYTES = 256 * 1024;
|
|
8
|
+
const MAX_LOG_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const MAX_PROGRESS_EVENTS = 64;
|
|
10
|
+
const DEADLINES = {
|
|
11
|
+
status: 15_000,
|
|
12
|
+
docs: 15_000,
|
|
13
|
+
context: 45_000,
|
|
14
|
+
explain: 60_000,
|
|
15
|
+
query: 60_000,
|
|
16
|
+
search: 90_000,
|
|
17
|
+
review: 90_000,
|
|
18
|
+
map: 120_000,
|
|
19
|
+
repair: 300_000,
|
|
20
|
+
index: 300_000,
|
|
21
|
+
};
|
|
22
|
+
export function operationDeadlineMs(operation) {
|
|
23
|
+
const override = Number.parseInt(process.env.KNODIN_MCP_DEADLINE_MS ?? "", 10);
|
|
24
|
+
if (Number.isSafeInteger(override) && override > 0)
|
|
25
|
+
return Math.min(override, 300_000);
|
|
26
|
+
return DEADLINES[operation] ?? 60_000;
|
|
27
|
+
}
|
|
28
|
+
/** Refuse new work when the local heap is already at a bounded pressure sentinel. */
|
|
29
|
+
export function assertMcpMemoryHeadroom() {
|
|
30
|
+
const configured = Number.parseInt(process.env.KNODIN_MCP_MEMORY_SENTINEL_BYTES ?? "", 10);
|
|
31
|
+
const limit = Number.isSafeInteger(configured) && configured > 0
|
|
32
|
+
? configured
|
|
33
|
+
: Math.floor(v8.getHeapStatistics().heap_size_limit * 0.9);
|
|
34
|
+
if (process.memoryUsage().heapUsed >= limit)
|
|
35
|
+
throw reliabilityError("KNODIN_MEMORY_PRESSURE", "MCP request refused by the local memory-pressure sentinel; retry after memory is released");
|
|
36
|
+
}
|
|
37
|
+
export function createMcpRequestContext(requestId, operation, repoPath) {
|
|
38
|
+
return {
|
|
39
|
+
requestId,
|
|
40
|
+
traceId: crypto.randomBytes(12).toString("hex"),
|
|
41
|
+
operation,
|
|
42
|
+
repoPath,
|
|
43
|
+
deadlineMs: operationDeadlineMs(operation),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function reliabilityError(code, message) {
|
|
47
|
+
return Object.assign(new Error(message), { code });
|
|
48
|
+
}
|
|
49
|
+
export async function superviseMcpRequest(context, signal, run, options = {}) {
|
|
50
|
+
if (signal.aborted)
|
|
51
|
+
throw reliabilityError("KNODIN_REQUEST_CANCELLED", "MCP request cancelled before dispatch");
|
|
52
|
+
let timer;
|
|
53
|
+
let onAbort;
|
|
54
|
+
const operation = new AbortController();
|
|
55
|
+
const task = run(operation.signal);
|
|
56
|
+
if (options.cooperativeCancellation !== true)
|
|
57
|
+
return await task;
|
|
58
|
+
let terminalError;
|
|
59
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
60
|
+
timer = setTimeout(() => {
|
|
61
|
+
operation.abort("deadline");
|
|
62
|
+
terminalError = reliabilityError("KNODIN_DEADLINE_EXCEEDED", `${context.operation} exceeded its ${context.deadlineMs}ms deadline`);
|
|
63
|
+
reject(terminalError);
|
|
64
|
+
}, context.deadlineMs);
|
|
65
|
+
});
|
|
66
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
67
|
+
onAbort = () => {
|
|
68
|
+
operation.abort(signal.reason);
|
|
69
|
+
terminalError = reliabilityError("KNODIN_REQUEST_CANCELLED", "MCP request cancelled by client");
|
|
70
|
+
reject(terminalError);
|
|
71
|
+
};
|
|
72
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
return await Promise.race([task, deadline, cancelled]);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (terminalError) {
|
|
79
|
+
try {
|
|
80
|
+
await task;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// The classified cancellation/deadline remains the public failure.
|
|
84
|
+
}
|
|
85
|
+
throw terminalError;
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
if (timer)
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
if (onAbort)
|
|
93
|
+
signal.removeEventListener("abort", onAbort);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function classifyMcpFailure(error) {
|
|
97
|
+
const code = typeof error?.code === "string" ? error.code : "";
|
|
98
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
99
|
+
if (code === "KNODIN_REQUEST_CANCELLED")
|
|
100
|
+
return { kind: "cancelled", code };
|
|
101
|
+
if (code === "KNODIN_CLIENT_DISCONNECTED" ||
|
|
102
|
+
/transport closed|broken pipe|client disconnect/i.test(message))
|
|
103
|
+
return { kind: "client-disconnected", code: "KNODIN_CLIENT_DISCONNECTED" };
|
|
104
|
+
if (code === "KNODIN_DEADLINE_EXCEEDED")
|
|
105
|
+
return { kind: "deadline-exceeded", code };
|
|
106
|
+
if (code === "SQLITE_BUSY" ||
|
|
107
|
+
code === "SQLITE_LOCKED" ||
|
|
108
|
+
/database is locked|graph lock/i.test(message))
|
|
109
|
+
return { kind: "graph-locked", code: "KNODIN_GRAPH_LOCKED" };
|
|
110
|
+
if (code === "KNODIN_MEMORY_PRESSURE" ||
|
|
111
|
+
code === "ERR_WORKER_OUT_OF_MEMORY" ||
|
|
112
|
+
/heap out of memory|memory pressure/i.test(message))
|
|
113
|
+
return { kind: "memory-pressure", code: "KNODIN_MEMORY_PRESSURE" };
|
|
114
|
+
if (code === "ERR_WORKER_NOT_RUNNING" || /worker (?:exited|crashed|terminated)/i.test(message))
|
|
115
|
+
return { kind: "worker-crashed", code: "KNODIN_WORKER_CRASHED" };
|
|
116
|
+
return { kind: "operation-failed", code: "KNODIN_OPERATION_FAILED" };
|
|
117
|
+
}
|
|
118
|
+
function safeRepo(repoPath) {
|
|
119
|
+
try {
|
|
120
|
+
return fs.realpathSync(repoPath);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Synchronous, metadata-only journal: each complete line survives abrupt process exit. */
|
|
127
|
+
export function recordMcpLifecycle(context, event, detail = {}) {
|
|
128
|
+
const repo = safeRepo(context.repoPath);
|
|
129
|
+
if (!repo || (detail.sequence ?? 0) > MAX_PROGRESS_EVENTS)
|
|
130
|
+
return;
|
|
131
|
+
try {
|
|
132
|
+
const target = path.join(repo, LOG_PATH);
|
|
133
|
+
const record = {
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
at: new Date().toISOString(),
|
|
136
|
+
event,
|
|
137
|
+
requestId: context.requestId,
|
|
138
|
+
traceId: context.traceId,
|
|
139
|
+
operation: context.operation,
|
|
140
|
+
deadlineMs: context.deadlineMs,
|
|
141
|
+
...detail,
|
|
142
|
+
};
|
|
143
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
144
|
+
repositoryBoundWrite(repo, target, line, "append", MAX_LOG_BYTES, MAX_LOG_AGE_MS);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Reliability logging must never break the request path.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export const mcpReliabilityBounds = {
|
|
151
|
+
maxLogBytes: MAX_LOG_BYTES,
|
|
152
|
+
maxLogAgeMs: MAX_LOG_AGE_MS,
|
|
153
|
+
maxProgressEvents: MAX_PROGRESS_EVENTS,
|
|
154
|
+
};
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { gitExecutable } from "./git-executable.js";
|
|
8
|
+
import { operationDeadlineMs, recordMcpLifecycle, } from "./mcp-reliability.js";
|
|
9
|
+
const IPC_VERSION = 1;
|
|
10
|
+
const CANCEL_GRACE_MS = 250;
|
|
11
|
+
const MAX_RESTARTS = 3;
|
|
12
|
+
const RESTART_WINDOW_MS = 60_000;
|
|
13
|
+
function codedError(code, message) {
|
|
14
|
+
return Object.assign(new Error(message), { code });
|
|
15
|
+
}
|
|
16
|
+
function defaultWorkerCommand() {
|
|
17
|
+
const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
|
|
18
|
+
const entry = fileURLToPath(new URL(`../src/mcp-graph-worker.${extension}`, import.meta.url));
|
|
19
|
+
return { command: process.execPath, args: [...process.execArgv, entry] };
|
|
20
|
+
}
|
|
21
|
+
export class RepositoryWorker extends EventEmitter {
|
|
22
|
+
repoPath;
|
|
23
|
+
workerCommand;
|
|
24
|
+
child;
|
|
25
|
+
ready;
|
|
26
|
+
pending = new Map();
|
|
27
|
+
tail = Promise.resolve();
|
|
28
|
+
restartTimes = [];
|
|
29
|
+
lastFailureTraceId;
|
|
30
|
+
constructor(repoPath, workerCommand = defaultWorkerCommand()) {
|
|
31
|
+
super();
|
|
32
|
+
this.repoPath = repoPath;
|
|
33
|
+
this.workerCommand = workerCommand;
|
|
34
|
+
}
|
|
35
|
+
start() {
|
|
36
|
+
if (this.child && this.ready)
|
|
37
|
+
return this.ready;
|
|
38
|
+
const child = spawn(this.workerCommand.command, this.workerCommand.args, {
|
|
39
|
+
cwd: this.repoPath,
|
|
40
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
41
|
+
env: { ...process.env, KNODIN_MCP_GRAPH_WORKER: "1" },
|
|
42
|
+
});
|
|
43
|
+
this.child = child;
|
|
44
|
+
child.unref();
|
|
45
|
+
child.stdin?.unref?.();
|
|
46
|
+
child.stdout?.unref?.();
|
|
47
|
+
child.stderr?.unref?.();
|
|
48
|
+
// Drain raw diagnostics so pipe backpressure cannot stall the worker; never persist source text.
|
|
49
|
+
child.stderr?.resume();
|
|
50
|
+
this.ready = new Promise((resolve, reject) => {
|
|
51
|
+
// Deliberately fixed, not configurable. This kill is the recovery path
|
|
52
|
+
// for a wedged worker, not merely a guard against a slow one: the
|
|
53
|
+
// supervisor restarts after it fires. Raising it delays recovery by
|
|
54
|
+
// exactly the amount raised, which surfaces as the next request
|
|
55
|
+
// burning its whole deadline in startup rather than as slowness here.
|
|
56
|
+
const startup = setTimeout(() => child.kill("SIGKILL"), 10_000);
|
|
57
|
+
if (!child.stdout)
|
|
58
|
+
throw codedError("KNODIN_WORKER_CRASHED", "graph worker stdout unavailable");
|
|
59
|
+
const lines = readline.createInterface({ input: child.stdout });
|
|
60
|
+
lines.on("line", (line) => {
|
|
61
|
+
let message;
|
|
62
|
+
try {
|
|
63
|
+
message = JSON.parse(line);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
this.failProtocol(child, "graph worker emitted malformed JSON");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (message.version !== IPC_VERSION) {
|
|
70
|
+
this.failProtocol(child, "graph worker IPC version mismatch");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (message.type === "ready") {
|
|
74
|
+
clearTimeout(startup);
|
|
75
|
+
resolve();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.receive(child, message);
|
|
79
|
+
});
|
|
80
|
+
child.once("error", reject);
|
|
81
|
+
child.once("exit", (code, signal) => reject(codedError("KNODIN_WORKER_CRASHED", `graph worker exited during startup (${signal ?? code ?? "unknown"})`)));
|
|
82
|
+
});
|
|
83
|
+
child.once("exit", (code, signal) => {
|
|
84
|
+
const observe = () => this.exited(child, code, signal);
|
|
85
|
+
const delay = this.workerCommand.exitObservationDelayMs ?? 0;
|
|
86
|
+
if (delay > 0)
|
|
87
|
+
setTimeout(observe, delay);
|
|
88
|
+
else
|
|
89
|
+
observe();
|
|
90
|
+
});
|
|
91
|
+
return this.ready;
|
|
92
|
+
}
|
|
93
|
+
receive(child, message) {
|
|
94
|
+
if (message.type === "protocol-error") {
|
|
95
|
+
this.failProtocol(child, "graph worker rejected the IPC request");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!("id" in message))
|
|
99
|
+
return;
|
|
100
|
+
const pending = this.pending.get(message.id);
|
|
101
|
+
if (pending?.child !== child)
|
|
102
|
+
return;
|
|
103
|
+
if (message.type === "progress") {
|
|
104
|
+
pending.onProgress?.(message.sequence, message.phase);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.finish(message.id);
|
|
108
|
+
if (message.type === "result")
|
|
109
|
+
pending.terminalError
|
|
110
|
+
? pending.reject(pending.terminalError)
|
|
111
|
+
: pending.resolve(message.result);
|
|
112
|
+
else if (message.type === "error")
|
|
113
|
+
pending.reject(pending.terminalError ??
|
|
114
|
+
codedError(message.error.code ?? "KNODIN_OPERATION_FAILED", message.error.message));
|
|
115
|
+
}
|
|
116
|
+
failProtocol(child, message) {
|
|
117
|
+
for (const [id, pending] of this.pending) {
|
|
118
|
+
if (pending.child !== child)
|
|
119
|
+
continue;
|
|
120
|
+
this.lastFailureTraceId = pending.context.traceId;
|
|
121
|
+
this.finish(id);
|
|
122
|
+
pending.reject(codedError("KNODIN_WORKER_PROTOCOL", message));
|
|
123
|
+
}
|
|
124
|
+
if (this.child === child) {
|
|
125
|
+
this.child = undefined;
|
|
126
|
+
this.ready = undefined;
|
|
127
|
+
}
|
|
128
|
+
child.kill("SIGKILL");
|
|
129
|
+
}
|
|
130
|
+
finish(id) {
|
|
131
|
+
const pending = this.pending.get(id);
|
|
132
|
+
if (!pending)
|
|
133
|
+
return;
|
|
134
|
+
clearTimeout(pending.timer);
|
|
135
|
+
if (pending.cancelTimer)
|
|
136
|
+
clearTimeout(pending.cancelTimer);
|
|
137
|
+
pending.signal.removeEventListener("abort", pending.cancel);
|
|
138
|
+
this.pending.delete(id);
|
|
139
|
+
}
|
|
140
|
+
exited(child, code, signal) {
|
|
141
|
+
if (this.child === child) {
|
|
142
|
+
this.child = undefined;
|
|
143
|
+
this.ready = undefined;
|
|
144
|
+
}
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
this.restartTimes = this.restartTimes.filter((at) => now - at < RESTART_WINDOW_MS);
|
|
147
|
+
for (const [id, pending] of this.pending) {
|
|
148
|
+
if (pending.child !== child)
|
|
149
|
+
continue;
|
|
150
|
+
this.lastFailureTraceId = pending.context.traceId;
|
|
151
|
+
this.finish(id);
|
|
152
|
+
pending.reject(pending.terminalError ??
|
|
153
|
+
codedError("KNODIN_WORKER_CRASHED", `graph worker exited (${signal ?? code ?? "unknown"})`));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
terminate(id, code, message) {
|
|
157
|
+
const pending = this.pending.get(id);
|
|
158
|
+
if (!pending)
|
|
159
|
+
return;
|
|
160
|
+
this.lastFailureTraceId = pending.context.traceId;
|
|
161
|
+
this.child?.stdin?.write(`${JSON.stringify({ version: IPC_VERSION, type: "cancel", id })}\n`);
|
|
162
|
+
pending.terminalError = codedError(code, message);
|
|
163
|
+
pending.cancelTimer = setTimeout(() => {
|
|
164
|
+
if (!this.pending.has(id))
|
|
165
|
+
return;
|
|
166
|
+
this.child?.kill("SIGKILL");
|
|
167
|
+
}, CANCEL_GRACE_MS);
|
|
168
|
+
}
|
|
169
|
+
async execute(context, args, signal, onProgress) {
|
|
170
|
+
const deadlineMs = operationDeadlineMs(context.operation);
|
|
171
|
+
const deadlineAt = Date.now() + deadlineMs;
|
|
172
|
+
const predecessor = this.tail;
|
|
173
|
+
let release;
|
|
174
|
+
this.tail = new Promise((resolve) => {
|
|
175
|
+
release = resolve;
|
|
176
|
+
});
|
|
177
|
+
let queueTimer;
|
|
178
|
+
let queueAbort;
|
|
179
|
+
let acquired = false;
|
|
180
|
+
try {
|
|
181
|
+
await Promise.race([
|
|
182
|
+
predecessor,
|
|
183
|
+
new Promise((_resolve, reject) => {
|
|
184
|
+
queueTimer = setTimeout(() => reject(codedError("KNODIN_DEADLINE_EXCEEDED", `${context.operation} exceeded its ${deadlineMs}ms deadline while queued`)), deadlineMs);
|
|
185
|
+
queueAbort = () => reject(codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected while queued"));
|
|
186
|
+
signal.addEventListener("abort", queueAbort, { once: true });
|
|
187
|
+
}),
|
|
188
|
+
]);
|
|
189
|
+
acquired = true;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
void predecessor.finally(release);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
if (queueTimer)
|
|
197
|
+
clearTimeout(queueTimer);
|
|
198
|
+
if (queueAbort)
|
|
199
|
+
signal.removeEventListener("abort", queueAbort);
|
|
200
|
+
}
|
|
201
|
+
const { requestId, operation } = context;
|
|
202
|
+
try {
|
|
203
|
+
if (signal.aborted)
|
|
204
|
+
throw codedError("KNODIN_REQUEST_CANCELLED", "request cancelled before worker dispatch");
|
|
205
|
+
if (!this.child) {
|
|
206
|
+
if (this.restartTimes.length >= MAX_RESTARTS)
|
|
207
|
+
throw codedError("KNODIN_RESTART_LIMIT", "graph worker restart limit reached");
|
|
208
|
+
this.restartTimes.push(Date.now());
|
|
209
|
+
}
|
|
210
|
+
const startup = this.start();
|
|
211
|
+
const remainingForStartup = Math.max(1, deadlineAt - Date.now());
|
|
212
|
+
let startupTimer;
|
|
213
|
+
let startupAbort;
|
|
214
|
+
let startupTerminalError;
|
|
215
|
+
await Promise.race([
|
|
216
|
+
startup.catch((error) => {
|
|
217
|
+
throw startupTerminalError ?? error;
|
|
218
|
+
}),
|
|
219
|
+
new Promise((_resolve, reject) => {
|
|
220
|
+
const terminate = (error) => {
|
|
221
|
+
this.lastFailureTraceId = context.traceId;
|
|
222
|
+
startupTerminalError = error;
|
|
223
|
+
this.child?.kill("SIGKILL");
|
|
224
|
+
startup.then(() => reject(error), () => reject(error));
|
|
225
|
+
};
|
|
226
|
+
startupTimer = setTimeout(() => terminate(codedError("KNODIN_DEADLINE_EXCEEDED", `${operation} exceeded its ${deadlineMs}ms deadline during worker startup`)), remainingForStartup);
|
|
227
|
+
startupAbort = () => terminate(codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected during worker startup"));
|
|
228
|
+
signal.addEventListener("abort", startupAbort, { once: true });
|
|
229
|
+
}),
|
|
230
|
+
]).finally(() => {
|
|
231
|
+
if (startupTimer)
|
|
232
|
+
clearTimeout(startupTimer);
|
|
233
|
+
if (startupAbort)
|
|
234
|
+
signal.removeEventListener("abort", startupAbort);
|
|
235
|
+
});
|
|
236
|
+
if (signal.aborted)
|
|
237
|
+
throw codedError("KNODIN_REQUEST_CANCELLED", "request cancelled during worker startup");
|
|
238
|
+
const predecessorTraceId = this.lastFailureTraceId;
|
|
239
|
+
if (predecessorTraceId)
|
|
240
|
+
recordMcpLifecycle(context, "restart", { predecessorTraceId });
|
|
241
|
+
const result = await new Promise((resolve, reject) => {
|
|
242
|
+
const remaining = Math.max(1, deadlineAt - Date.now());
|
|
243
|
+
const timer = setTimeout(() => this.terminate(requestId, "KNODIN_DEADLINE_EXCEEDED", `${operation} exceeded its ${deadlineMs}ms deadline`), remaining);
|
|
244
|
+
const cancel = () => this.terminate(requestId, "KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected or cancelled");
|
|
245
|
+
const child = this.child;
|
|
246
|
+
if (!child) {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
reject(codedError("KNODIN_WORKER_CRASHED", "graph worker unavailable"));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const pendingRecord = {
|
|
252
|
+
child,
|
|
253
|
+
context,
|
|
254
|
+
resolve,
|
|
255
|
+
reject,
|
|
256
|
+
onProgress,
|
|
257
|
+
timer,
|
|
258
|
+
signal,
|
|
259
|
+
cancel,
|
|
260
|
+
};
|
|
261
|
+
this.pending.set(requestId, pendingRecord);
|
|
262
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
263
|
+
const input = child.stdin;
|
|
264
|
+
if (!input) {
|
|
265
|
+
reject(codedError("KNODIN_WORKER_CRASHED", "graph worker stdin unavailable"));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
input.write(`${JSON.stringify({ version: IPC_VERSION, type: "request", id: requestId, args })}\n`, (error) => {
|
|
269
|
+
if (error) {
|
|
270
|
+
pendingRecord.terminalError = codedError("KNODIN_WORKER_CRASHED", "graph worker stdin write failed");
|
|
271
|
+
child.kill("SIGKILL");
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}).finally(() => this.finish(requestId));
|
|
275
|
+
if (predecessorTraceId)
|
|
276
|
+
recordMcpLifecycle(context, "recovery", { predecessorTraceId });
|
|
277
|
+
this.lastFailureTraceId = undefined;
|
|
278
|
+
return { result, ...(predecessorTraceId ? { predecessorTraceId } : {}) };
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
if (acquired)
|
|
282
|
+
release();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
killForTest() {
|
|
286
|
+
this.child?.kill("SIGKILL");
|
|
287
|
+
}
|
|
288
|
+
async close() {
|
|
289
|
+
const child = this.child;
|
|
290
|
+
if (child?.exitCode !== null)
|
|
291
|
+
return;
|
|
292
|
+
await new Promise((resolve) => {
|
|
293
|
+
let escalation;
|
|
294
|
+
let bound;
|
|
295
|
+
const exited = () => {
|
|
296
|
+
if (escalation)
|
|
297
|
+
clearTimeout(escalation);
|
|
298
|
+
if (bound)
|
|
299
|
+
clearTimeout(bound);
|
|
300
|
+
resolve();
|
|
301
|
+
};
|
|
302
|
+
child.once("exit", exited);
|
|
303
|
+
if (child.exitCode !== null) {
|
|
304
|
+
child.off("exit", exited);
|
|
305
|
+
exited();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
child.kill("SIGTERM");
|
|
309
|
+
escalation = setTimeout(() => {
|
|
310
|
+
child.kill("SIGKILL");
|
|
311
|
+
bound = setTimeout(() => {
|
|
312
|
+
child.off("exit", exited);
|
|
313
|
+
resolve();
|
|
314
|
+
}, 500);
|
|
315
|
+
}, 500);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
export class WorkerSupervisor {
|
|
320
|
+
workers = new Map();
|
|
321
|
+
exitHandler = () => void this.close();
|
|
322
|
+
constructor() {
|
|
323
|
+
process.once("exit", this.exitHandler);
|
|
324
|
+
}
|
|
325
|
+
worker(repoPath) {
|
|
326
|
+
const resolved = fs.realpathSync(path.resolve(repoPath));
|
|
327
|
+
let key = resolved;
|
|
328
|
+
try {
|
|
329
|
+
key = fs.realpathSync(execFileSync(gitExecutable(), ["rev-parse", "--show-toplevel"], {
|
|
330
|
+
cwd: resolved,
|
|
331
|
+
encoding: "utf8",
|
|
332
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
333
|
+
}).trim());
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
/* Non-Git fixtures retain their canonical directory identity. */
|
|
337
|
+
}
|
|
338
|
+
let worker = this.workers.get(key);
|
|
339
|
+
if (!worker) {
|
|
340
|
+
worker = new RepositoryWorker(key);
|
|
341
|
+
this.workers.set(key, worker);
|
|
342
|
+
}
|
|
343
|
+
return worker;
|
|
344
|
+
}
|
|
345
|
+
async close() {
|
|
346
|
+
await Promise.all([...this.workers.values()].map((worker) => worker.close()));
|
|
347
|
+
this.workers.clear();
|
|
348
|
+
process.off("exit", this.exitHandler);
|
|
349
|
+
}
|
|
350
|
+
}
|