@flyingrobots/graft 0.4.0 → 0.5.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 (111) hide show
  1. package/ARCHITECTURE.md +386 -0
  2. package/CHANGELOG.md +47 -0
  3. package/CODE_OF_CONDUCT.md +65 -0
  4. package/README.md +153 -17
  5. package/bin/graft.js +4 -14
  6. package/docs/ADVANCED_GUIDE.md +49 -0
  7. package/docs/CLI.md +43 -0
  8. package/docs/GUIDE.md +321 -32
  9. package/docs/MCP.md +44 -0
  10. package/package.json +15 -4
  11. package/src/adapters/node-fs.ts +4 -0
  12. package/src/adapters/node-git.ts +47 -0
  13. package/src/adapters/node-process-runner.ts +27 -0
  14. package/src/cli/index-cmd.ts +75 -11
  15. package/src/cli/init.ts +808 -57
  16. package/src/cli/main.ts +437 -0
  17. package/src/contracts/capabilities.ts +341 -0
  18. package/src/contracts/causal-ontology.ts +622 -0
  19. package/src/contracts/causal-surface-next-action.ts +18 -0
  20. package/src/contracts/output-schemas.ts +1169 -0
  21. package/src/git/diff.ts +25 -21
  22. package/src/git/target-git-hook-bootstrap.ts +56 -0
  23. package/src/hooks/posttooluse-read.ts +21 -74
  24. package/src/hooks/pretooluse-read.ts +20 -56
  25. package/src/hooks/read-governor.ts +95 -0
  26. package/src/hooks/read-messages.ts +53 -0
  27. package/src/mcp/burden.ts +123 -0
  28. package/src/mcp/cache.ts +51 -0
  29. package/src/mcp/cached-file.ts +10 -8
  30. package/src/mcp/context.ts +65 -2
  31. package/src/mcp/daemon-control-plane.ts +554 -0
  32. package/src/mcp/daemon-job-scheduler.ts +279 -0
  33. package/src/mcp/daemon-repos.ts +216 -0
  34. package/src/mcp/daemon-server.ts +396 -0
  35. package/src/mcp/daemon-worker-pool.ts +310 -0
  36. package/src/mcp/daemon-worker-process.ts +52 -0
  37. package/src/mcp/metrics.ts +108 -1
  38. package/src/mcp/monitor-tick-job.ts +99 -0
  39. package/src/mcp/persisted-local-history.ts +1246 -0
  40. package/src/mcp/persistent-monitor-runtime.ts +549 -0
  41. package/src/mcp/policy.ts +84 -0
  42. package/src/mcp/receipt.ts +82 -12
  43. package/src/mcp/repo-concurrency.ts +318 -0
  44. package/src/mcp/repo-state.ts +777 -0
  45. package/src/mcp/repo-tool-job.ts +302 -0
  46. package/src/mcp/run-capture-config.ts +33 -0
  47. package/src/mcp/runtime-causal-context.ts +72 -0
  48. package/src/mcp/runtime-observability.ts +219 -0
  49. package/src/mcp/runtime-staged-target.ts +161 -0
  50. package/src/mcp/runtime-workspace-overlay.ts +255 -0
  51. package/src/mcp/semantic-transition-guidance.ts +60 -0
  52. package/src/mcp/semantic-transition-summary.ts +130 -0
  53. package/src/mcp/server.ts +696 -55
  54. package/src/mcp/stdio-server.ts +12 -0
  55. package/src/mcp/stdio.ts +2 -5
  56. package/src/mcp/tools/activity-view.ts +325 -0
  57. package/src/mcp/tools/causal-attach.ts +67 -0
  58. package/src/mcp/tools/causal-status.ts +58 -0
  59. package/src/mcp/tools/changed-since.ts +13 -11
  60. package/src/mcp/tools/code-find.ts +164 -0
  61. package/src/mcp/tools/code-refs.ts +466 -0
  62. package/src/mcp/tools/code-show.ts +252 -0
  63. package/src/mcp/tools/daemon-monitors.ts +14 -0
  64. package/src/mcp/tools/daemon-repos.ts +22 -0
  65. package/src/mcp/tools/daemon-sessions.ts +14 -0
  66. package/src/mcp/tools/daemon-status.ts +12 -0
  67. package/src/mcp/tools/doctor.ts +45 -2
  68. package/src/mcp/tools/explain.ts +4 -0
  69. package/src/mcp/tools/file-outline.ts +7 -3
  70. package/src/mcp/tools/git-files.ts +73 -0
  71. package/src/mcp/tools/graft-diff.ts +12 -4
  72. package/src/mcp/tools/map.ts +92 -38
  73. package/src/mcp/tools/monitor-pause.ts +18 -0
  74. package/src/mcp/tools/monitor-resume.ts +18 -0
  75. package/src/mcp/tools/monitor-start.ts +20 -0
  76. package/src/mcp/tools/monitor-stop.ts +18 -0
  77. package/src/mcp/tools/precision-match.ts +51 -0
  78. package/src/mcp/tools/precision-query.ts +127 -0
  79. package/src/mcp/tools/precision.ts +312 -0
  80. package/src/mcp/tools/run-capture.ts +126 -44
  81. package/src/mcp/tools/safe-read.ts +14 -12
  82. package/src/mcp/tools/since.ts +7 -2
  83. package/src/mcp/tools/state.ts +11 -3
  84. package/src/mcp/tools/stats.ts +5 -1
  85. package/src/mcp/tools/workspace-authorizations.ts +14 -0
  86. package/src/mcp/tools/workspace-authorize.ts +20 -0
  87. package/src/mcp/tools/workspace-bind.ts +25 -0
  88. package/src/mcp/tools/workspace-rebind.ts +25 -0
  89. package/src/mcp/tools/workspace-revoke.ts +18 -0
  90. package/src/mcp/tools/workspace-status.ts +12 -0
  91. package/src/mcp/warp-pool.ts +36 -0
  92. package/src/mcp/workspace-router.ts +984 -0
  93. package/src/operations/file-outline.ts +12 -2
  94. package/src/operations/graft-diff.ts +56 -10
  95. package/src/operations/safe-read.ts +27 -4
  96. package/src/operations/state.ts +6 -9
  97. package/src/parser/lang.ts +19 -3
  98. package/src/parser/outline.ts +191 -2
  99. package/src/parser/types.ts +9 -1
  100. package/src/policy/types.ts +4 -3
  101. package/src/ports/filesystem.ts +1 -0
  102. package/src/ports/git.ts +16 -0
  103. package/src/ports/process-runner.ts +22 -0
  104. package/src/release/security-gate.ts +102 -0
  105. package/src/session/tracker.ts +31 -0
  106. package/src/version.ts +3 -0
  107. package/src/warp/indexer.ts +171 -56
  108. package/src/warp/observers.ts +1 -1
  109. package/src/warp/open.ts +4 -3
  110. package/src/warp/plumbing.d.ts +5 -1
  111. package/src/warp/writer-id.ts +30 -0
@@ -0,0 +1,396 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as http from "node:http";
4
+ import * as net from "node:net";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
9
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
10
+ import { CanonicalJsonCodec } from "../adapters/canonical-json.js";
11
+ import { nodeFs } from "../adapters/node-fs.js";
12
+ import { nodeGit } from "../adapters/node-git.js";
13
+ import { createGraftServer, type GraftServer } from "./server.js";
14
+ import { DaemonControlPlane, type DaemonStatusView } from "./daemon-control-plane.js";
15
+ import { DaemonJobScheduler } from "./daemon-job-scheduler.js";
16
+ import { ChildProcessDaemonWorkerPool } from "./daemon-worker-pool.js";
17
+ import { PersistentMonitorRuntime } from "./persistent-monitor-runtime.js";
18
+ import { InMemoryWarpPool } from "./warp-pool.js";
19
+ import { openWarp } from "../warp/open.js";
20
+ import type { RunCaptureConfig } from "./run-capture-config.js";
21
+ import type { RuntimeObservabilityState } from "./runtime-observability.js";
22
+
23
+ const HEALTH_PATH = "/healthz";
24
+ const MCP_PATH = "/mcp";
25
+ const DIRECTORY_MODE = 0o700;
26
+ const SOCKET_MODE = 0o600;
27
+ const MAX_BODY_BYTES = 1024 * 1024;
28
+
29
+ interface DaemonSession {
30
+ readonly id: string;
31
+ readonly graftDir: string;
32
+ readonly transport: StreamableHTTPServerTransport;
33
+ readonly server: GraftServer;
34
+ }
35
+
36
+ export type DaemonHealthStatus = DaemonStatusView;
37
+
38
+ export interface StartDaemonServerOptions {
39
+ readonly socketPath?: string | undefined;
40
+ readonly graftDir?: string | undefined;
41
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
42
+ readonly runCapture?: Partial<RunCaptureConfig> | undefined;
43
+ readonly runtimeObservability?: Partial<RuntimeObservabilityState> | undefined;
44
+ }
45
+
46
+ export interface GraftDaemonServer {
47
+ readonly socketPath: string;
48
+ readonly healthPath: typeof HEALTH_PATH;
49
+ readonly mcpPath: typeof MCP_PATH;
50
+ close(): Promise<void>;
51
+ getHealthStatus(): DaemonHealthStatus;
52
+ }
53
+
54
+ function isNamedPipePath(socketPath: string): boolean {
55
+ return process.platform === "win32" && socketPath.startsWith("\\\\.\\pipe\\");
56
+ }
57
+
58
+ function defaultDaemonRoot(): string {
59
+ return path.join(os.homedir(), ".graft", "daemon");
60
+ }
61
+
62
+ function defaultSocketPath(graftDir: string): string {
63
+ if (process.platform === "win32") {
64
+ const digest = crypto.createHash("sha256").update(os.homedir()).digest("hex").slice(0, 12);
65
+ return `\\\\.\\pipe\\graft-daemon-${digest}`;
66
+ }
67
+ return path.join(graftDir, "mcp.sock");
68
+ }
69
+
70
+ function resolveSocketPath(socketPath: string | undefined, graftDir: string): string {
71
+ if (socketPath === undefined) return defaultSocketPath(graftDir);
72
+ if (isNamedPipePath(socketPath)) return socketPath;
73
+ return path.resolve(socketPath);
74
+ }
75
+
76
+ async function ensurePrivateDirectory(dirPath: string): Promise<void> {
77
+ await fs.mkdir(dirPath, { recursive: true, mode: DIRECTORY_MODE });
78
+ if (process.platform !== "win32") {
79
+ await fs.chmod(dirPath, DIRECTORY_MODE).catch(() => {
80
+ return undefined;
81
+ });
82
+ }
83
+ }
84
+
85
+ async function socketHasActiveListener(socketPath: string): Promise<boolean> {
86
+ return new Promise<boolean>((resolve) => {
87
+ const socket = net.createConnection({ path: socketPath });
88
+ let settled = false;
89
+
90
+ const finish = (result: boolean): void => {
91
+ if (settled) return;
92
+ settled = true;
93
+ socket.destroy();
94
+ resolve(result);
95
+ };
96
+
97
+ socket.once("connect", () => {
98
+ finish(true);
99
+ });
100
+ socket.once("error", () => {
101
+ finish(false);
102
+ });
103
+ socket.setTimeout(200, () => {
104
+ finish(false);
105
+ });
106
+ });
107
+ }
108
+
109
+ async function prepareSocketPath(socketPath: string): Promise<void> {
110
+ if (isNamedPipePath(socketPath)) return;
111
+ await ensurePrivateDirectory(path.dirname(socketPath));
112
+ const existing = await fs.lstat(socketPath).catch((error: unknown) => {
113
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
114
+ throw error;
115
+ });
116
+ if (existing === null) return;
117
+ if (!existing.isSocket()) {
118
+ throw new Error(`Refusing to overwrite non-socket path: ${socketPath}`);
119
+ }
120
+ if (await socketHasActiveListener(socketPath)) {
121
+ throw new Error(`A graft daemon is already listening on ${socketPath}`);
122
+ }
123
+ await fs.unlink(socketPath);
124
+ }
125
+
126
+ async function tightenSocketPermissions(socketPath: string): Promise<void> {
127
+ if (process.platform === "win32" || isNamedPipePath(socketPath)) return;
128
+ await fs.chmod(socketPath, SOCKET_MODE).catch(() => {
129
+ return undefined;
130
+ });
131
+ }
132
+
133
+ function getHeader(req: http.IncomingMessage, name: string): string | undefined {
134
+ const value = req.headers[name];
135
+ return Array.isArray(value) ? value[0] : value;
136
+ }
137
+
138
+ async function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
139
+ const chunks: Buffer[] = [];
140
+ let total = 0;
141
+ for await (const chunk of req as AsyncIterable<Buffer | string>) {
142
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf-8") : chunk;
143
+ total += buffer.length;
144
+ if (total > MAX_BODY_BYTES) {
145
+ throw new Error(`Request body exceeds ${String(MAX_BODY_BYTES)} bytes`);
146
+ }
147
+ chunks.push(buffer);
148
+ }
149
+ if (chunks.length === 0) return undefined;
150
+ return JSON.parse(Buffer.concat(chunks).toString("utf-8")) as unknown;
151
+ }
152
+
153
+ function sendJson(res: http.ServerResponse, statusCode: number, body: Record<string, unknown>): void {
154
+ if (res.headersSent) return;
155
+ res.writeHead(statusCode, { "content-type": "application/json" });
156
+ res.end(JSON.stringify(body));
157
+ }
158
+
159
+ function sendJsonRpcError(res: http.ServerResponse, code: number, message: string): void {
160
+ sendJson(res, code === -32700 ? 400 : 500, {
161
+ jsonrpc: "2.0",
162
+ error: { code, message },
163
+ id: null,
164
+ });
165
+ }
166
+
167
+ async function closeHttpServer(server: http.Server): Promise<void> {
168
+ await new Promise<void>((resolve, reject) => {
169
+ server.close((error) => {
170
+ if (error) {
171
+ reject(error);
172
+ return;
173
+ }
174
+ resolve();
175
+ });
176
+ });
177
+ }
178
+
179
+ export async function startDaemonServer(options: StartDaemonServerOptions = {}): Promise<GraftDaemonServer> {
180
+ const graftDir = path.resolve(options.graftDir ?? defaultDaemonRoot());
181
+ const socketPath = resolveSocketPath(options.socketPath, graftDir);
182
+ const warpPool = new InMemoryWarpPool((cwd) => openWarp({ cwd }));
183
+ const controlPlane = new DaemonControlPlane({
184
+ fs: nodeFs,
185
+ codec: new CanonicalJsonCodec(),
186
+ git: nodeGit,
187
+ graftDir,
188
+ });
189
+ const daemonScheduler = new DaemonJobScheduler();
190
+ const daemonWorkerPool = new ChildProcessDaemonWorkerPool();
191
+ const monitorRuntime = new PersistentMonitorRuntime({
192
+ fs: nodeFs,
193
+ codec: new CanonicalJsonCodec(),
194
+ git: nodeGit,
195
+ graftDir,
196
+ controlPlane,
197
+ scheduler: daemonScheduler,
198
+ workerPool: daemonWorkerPool,
199
+ });
200
+ const sessions = new Map<string, DaemonSession>();
201
+ const startedAt = new Date().toISOString();
202
+ const transportKind = isNamedPipePath(socketPath) ? "named_pipe" : "unix_socket";
203
+
204
+ const getHealthStatus = (): DaemonHealthStatus => {
205
+ return controlPlane.getStatus({
206
+ transport: transportKind,
207
+ sameUserOnly: true,
208
+ socketPath,
209
+ mcpPath: MCP_PATH,
210
+ healthPath: HEALTH_PATH,
211
+ activeWarpRepos: warpPool.size(),
212
+ startedAt,
213
+ }, monitorRuntime.getCounts(), daemonScheduler.getCounts(), daemonWorkerPool.getCounts());
214
+ };
215
+
216
+ await ensurePrivateDirectory(graftDir);
217
+ await ensurePrivateDirectory(path.join(graftDir, "sessions"));
218
+ await controlPlane.initialize();
219
+ await monitorRuntime.initialize();
220
+ await prepareSocketPath(socketPath);
221
+
222
+ const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse): Promise<void> => {
223
+ try {
224
+ const url = new URL(req.url ?? "/", "http://localhost");
225
+ if (req.method === "GET" && url.pathname === HEALTH_PATH) {
226
+ sendJson(res, 200, { ...getHealthStatus() });
227
+ return;
228
+ }
229
+
230
+ if (url.pathname !== MCP_PATH) {
231
+ sendJson(res, 404, { error: "Not found" });
232
+ return;
233
+ }
234
+
235
+ const sessionId = getHeader(req, "mcp-session-id");
236
+
237
+ if (req.method === "POST") {
238
+ const parsedBody = await readJsonBody(req);
239
+ let session = sessionId !== undefined ? sessions.get(sessionId) : undefined;
240
+ if (session === undefined) {
241
+ if (sessionId !== undefined) {
242
+ sendJsonRpcError(res, -32000, `Unknown MCP session: ${sessionId}`);
243
+ return;
244
+ }
245
+ if (!isInitializeRequest(parsedBody)) {
246
+ sendJsonRpcError(res, -32000, "Initialization requests must start a daemon session");
247
+ return;
248
+ }
249
+ const newSessionId = crypto.randomUUID();
250
+ const sessionGraftDir = path.join(graftDir, "sessions", newSessionId);
251
+ await ensurePrivateDirectory(sessionGraftDir);
252
+ const transport = new StreamableHTTPServerTransport({
253
+ sessionIdGenerator: () => newSessionId,
254
+ });
255
+ const server = createGraftServer({
256
+ mode: "daemon",
257
+ sessionId: newSessionId,
258
+ graftDir: sessionGraftDir,
259
+ warpPool,
260
+ daemonControlPlane: controlPlane,
261
+ daemonScheduler,
262
+ daemonWorkerPool,
263
+ daemonRuntime: () => ({
264
+ transport: transportKind,
265
+ sameUserOnly: true,
266
+ socketPath,
267
+ mcpPath: MCP_PATH,
268
+ healthPath: HEALTH_PATH,
269
+ activeWarpRepos: warpPool.size(),
270
+ startedAt,
271
+ }),
272
+ monitorRuntime,
273
+ ...(options.env !== undefined ? { env: options.env } : {}),
274
+ ...(options.runCapture !== undefined ? { runCapture: options.runCapture } : {}),
275
+ ...(options.runtimeObservability !== undefined
276
+ ? { runtimeObservability: options.runtimeObservability }
277
+ : {}),
278
+ });
279
+ const createdSession: DaemonSession = {
280
+ id: newSessionId,
281
+ graftDir: sessionGraftDir,
282
+ transport,
283
+ server,
284
+ };
285
+ session = createdSession;
286
+ transport.onclose = () => {
287
+ sessions.delete(newSessionId);
288
+ controlPlane.unregisterSession(newSessionId);
289
+ };
290
+ transport.onerror = () => {
291
+ sessions.delete(newSessionId);
292
+ controlPlane.unregisterSession(newSessionId);
293
+ };
294
+ sessions.set(newSessionId, createdSession);
295
+ controlPlane.registerSession(
296
+ newSessionId,
297
+ () => server.getWorkspaceStatus(),
298
+ () => server.getRuntimeCausalContext(),
299
+ );
300
+ await server.getMcpServer().connect(transport as Transport);
301
+ }
302
+
303
+ controlPlane.touchSession(session.id);
304
+ await session.transport.handleRequest(req, res, parsedBody);
305
+ return;
306
+ }
307
+
308
+ if (req.method === "GET" || req.method === "DELETE") {
309
+ if (sessionId === undefined) {
310
+ sendJson(res, 400, { error: "Missing MCP session header" });
311
+ return;
312
+ }
313
+ const session = sessions.get(sessionId);
314
+ if (session === undefined) {
315
+ sendJson(res, 404, { error: `Unknown MCP session: ${sessionId}` });
316
+ return;
317
+ }
318
+ controlPlane.touchSession(session.id);
319
+ await session.transport.handleRequest(req, res);
320
+ return;
321
+ }
322
+
323
+ sendJson(res, 405, { error: "Method not allowed" });
324
+ } catch (error) {
325
+ if (error instanceof SyntaxError) {
326
+ sendJsonRpcError(res, -32700, "Invalid JSON");
327
+ return;
328
+ }
329
+ sendJsonRpcError(res, -32603, error instanceof Error ? error.message : String(error));
330
+ }
331
+ };
332
+
333
+ const httpServer = http.createServer((req, res) => {
334
+ void handleRequest(req, res);
335
+ });
336
+
337
+ await new Promise<void>((resolve, reject) => {
338
+ const onError = (error: Error): void => {
339
+ httpServer.off("listening", onListening);
340
+ reject(error);
341
+ };
342
+ const onListening = (): void => {
343
+ httpServer.off("error", onError);
344
+ resolve();
345
+ };
346
+ httpServer.once("error", onError);
347
+ httpServer.once("listening", onListening);
348
+ httpServer.listen(socketPath);
349
+ });
350
+ await tightenSocketPermissions(socketPath);
351
+
352
+ let closing: Promise<void> | null = null;
353
+
354
+ const shutdown = (): void => {
355
+ void daemon.close().finally(() => {
356
+ process.exitCode = process.exitCode ?? 0;
357
+ });
358
+ };
359
+
360
+ process.once("SIGINT", shutdown);
361
+ process.once("SIGTERM", shutdown);
362
+
363
+ const daemon: GraftDaemonServer = {
364
+ socketPath,
365
+ healthPath: HEALTH_PATH,
366
+ mcpPath: MCP_PATH,
367
+ getHealthStatus(): DaemonHealthStatus {
368
+ return getHealthStatus();
369
+ },
370
+ async close(): Promise<void> {
371
+ if (closing !== null) return closing;
372
+ closing = (async () => {
373
+ process.off("SIGINT", shutdown);
374
+ process.off("SIGTERM", shutdown);
375
+ for (const session of sessions.values()) {
376
+ controlPlane.unregisterSession(session.id);
377
+ await session.transport.close().catch(() => {
378
+ return undefined;
379
+ });
380
+ }
381
+ sessions.clear();
382
+ await monitorRuntime.close();
383
+ await daemonWorkerPool.close();
384
+ await closeHttpServer(httpServer);
385
+ if (!isNamedPipePath(socketPath)) {
386
+ await fs.unlink(socketPath).catch(() => {
387
+ return undefined;
388
+ });
389
+ }
390
+ })();
391
+ return closing;
392
+ },
393
+ };
394
+
395
+ return daemon;
396
+ }
@@ -0,0 +1,310 @@
1
+ import * as crypto from "node:crypto";
2
+ import { fork, type ChildProcess } from "node:child_process";
3
+ import * as os from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import { runMonitorTickJob, type MonitorTickWorkerJob, type MonitorTickWorkerResult } from "./monitor-tick-job.js";
6
+ import { runRepoToolJob, type RepoToolWorkerJob, type RepoToolWorkerResult } from "./repo-tool-job.js";
7
+
8
+ export interface DaemonWorkerCounts {
9
+ readonly mode: "inline" | "child_processes";
10
+ readonly totalWorkers: number;
11
+ readonly busyWorkers: number;
12
+ readonly idleWorkers: number;
13
+ readonly queuedTasks: number;
14
+ readonly completedTasks: number;
15
+ readonly failedTasks: number;
16
+ }
17
+
18
+ export interface DaemonWorkerPool {
19
+ getCounts(): DaemonWorkerCounts;
20
+ runMonitorTick(job: MonitorTickWorkerJob): Promise<MonitorTickWorkerResult>;
21
+ runRepoTool(job: RepoToolWorkerJob): Promise<RepoToolWorkerResult>;
22
+ close(): Promise<void>;
23
+ }
24
+
25
+ export interface ChildProcessDaemonWorkerPoolOptions {
26
+ readonly size?: number | undefined;
27
+ }
28
+
29
+ interface QueuedMonitorTask {
30
+ readonly requestId: string;
31
+ readonly kind: "monitor_tick";
32
+ readonly job: MonitorTickWorkerJob;
33
+ readonly resolve: (result: MonitorTickWorkerResult) => void;
34
+ readonly reject: (reason?: unknown) => void;
35
+ }
36
+
37
+ interface QueuedRepoToolTask {
38
+ readonly requestId: string;
39
+ readonly kind: "repo_tool";
40
+ readonly job: RepoToolWorkerJob;
41
+ readonly resolve: (result: RepoToolWorkerResult) => void;
42
+ readonly reject: (reason?: unknown) => void;
43
+ }
44
+
45
+ type QueuedWorkerTask = QueuedMonitorTask | QueuedRepoToolTask;
46
+
47
+ type ActiveWorkerTask = QueuedWorkerTask & {
48
+ readonly workerId: string;
49
+ };
50
+
51
+ interface WorkerSuccessMessage {
52
+ readonly requestId: string;
53
+ readonly ok: true;
54
+ readonly result: MonitorTickWorkerResult | RepoToolWorkerResult;
55
+ }
56
+
57
+ interface WorkerFailureMessage {
58
+ readonly requestId: string;
59
+ readonly ok: false;
60
+ readonly error: string;
61
+ }
62
+
63
+ type WorkerMessage = WorkerSuccessMessage | WorkerFailureMessage;
64
+
65
+ interface WorkerState {
66
+ readonly workerId: string;
67
+ readonly child: ChildProcess;
68
+ task: ActiveWorkerTask | null;
69
+ }
70
+
71
+ function defaultProcessPoolSize(): number {
72
+ const parallelism = os.availableParallelism();
73
+ return Math.max(1, Math.min(4, parallelism > 1 ? parallelism - 1 : 1));
74
+ }
75
+
76
+ function normalizeProcessPoolSize(value: number | undefined): number {
77
+ if (value === undefined) return defaultProcessPoolSize();
78
+ const normalized = Math.trunc(value);
79
+ if (!Number.isFinite(normalized) || normalized <= 0) {
80
+ throw new Error("worker pool size must be a positive integer");
81
+ }
82
+ return normalized;
83
+ }
84
+
85
+ function daemonWorkerProcessPath(): string {
86
+ return fileURLToPath(new URL("./daemon-worker-process.ts", import.meta.url));
87
+ }
88
+
89
+ export class InlineDaemonWorkerPool implements DaemonWorkerPool {
90
+ private completedTasks = 0;
91
+ private failedTasks = 0;
92
+
93
+ getCounts(): DaemonWorkerCounts {
94
+ return {
95
+ mode: "inline",
96
+ totalWorkers: 0,
97
+ busyWorkers: 0,
98
+ idleWorkers: 0,
99
+ queuedTasks: 0,
100
+ completedTasks: this.completedTasks,
101
+ failedTasks: this.failedTasks,
102
+ };
103
+ }
104
+
105
+ async runMonitorTick(job: MonitorTickWorkerJob): Promise<MonitorTickWorkerResult> {
106
+ try {
107
+ const result = await runMonitorTickJob(job);
108
+ this.completedTasks++;
109
+ return result;
110
+ } catch (error) {
111
+ this.failedTasks++;
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ async runRepoTool(job: RepoToolWorkerJob): Promise<RepoToolWorkerResult> {
117
+ try {
118
+ const result = await runRepoToolJob(job);
119
+ this.completedTasks++;
120
+ return result;
121
+ } catch (error) {
122
+ this.failedTasks++;
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ close(): Promise<void> {
128
+ return Promise.resolve();
129
+ }
130
+ }
131
+
132
+ export class ChildProcessDaemonWorkerPool implements DaemonWorkerPool {
133
+ private readonly size: number;
134
+ private readonly workers = new Map<string, WorkerState>();
135
+ private readonly queue: QueuedWorkerTask[] = [];
136
+ private completedTasks = 0;
137
+ private failedTasks = 0;
138
+ private closing = false;
139
+
140
+ constructor(options: ChildProcessDaemonWorkerPoolOptions = {}) {
141
+ this.size = normalizeProcessPoolSize(options.size);
142
+ for (let index = 0; index < this.size; index++) {
143
+ this.spawnWorker();
144
+ }
145
+ }
146
+
147
+ getCounts(): DaemonWorkerCounts {
148
+ const busyWorkers = [...this.workers.values()].filter((workerState) => workerState.task !== null).length;
149
+ return {
150
+ mode: "child_processes",
151
+ totalWorkers: this.workers.size,
152
+ busyWorkers,
153
+ idleWorkers: this.workers.size - busyWorkers,
154
+ queuedTasks: this.queue.length,
155
+ completedTasks: this.completedTasks,
156
+ failedTasks: this.failedTasks,
157
+ };
158
+ }
159
+
160
+ runMonitorTick(job: MonitorTickWorkerJob): Promise<MonitorTickWorkerResult> {
161
+ if (this.closing) {
162
+ return Promise.reject(new Error("daemon worker pool is closing"));
163
+ }
164
+ return new Promise<MonitorTickWorkerResult>((resolve, reject) => {
165
+ this.queue.push({
166
+ requestId: crypto.randomUUID(),
167
+ kind: "monitor_tick",
168
+ job,
169
+ resolve,
170
+ reject,
171
+ });
172
+ this.dispatch();
173
+ });
174
+ }
175
+
176
+ runRepoTool(job: RepoToolWorkerJob): Promise<RepoToolWorkerResult> {
177
+ if (this.closing) {
178
+ return Promise.reject(new Error("daemon worker pool is closing"));
179
+ }
180
+ return new Promise<RepoToolWorkerResult>((resolve, reject) => {
181
+ this.queue.push({
182
+ requestId: crypto.randomUUID(),
183
+ kind: "repo_tool",
184
+ job,
185
+ resolve,
186
+ reject,
187
+ });
188
+ this.dispatch();
189
+ });
190
+ }
191
+
192
+ async close(): Promise<void> {
193
+ if (this.closing) return;
194
+ this.closing = true;
195
+ const pending = [...this.queue];
196
+ this.queue.length = 0;
197
+ for (const task of pending) {
198
+ task.reject(new Error("daemon worker pool closed before task execution"));
199
+ }
200
+ const active = [...this.workers.values()].flatMap((workerState) => {
201
+ return workerState.task === null ? [] : [workerState.task];
202
+ });
203
+ for (const task of active) {
204
+ task.reject(new Error("daemon worker pool closed during task execution"));
205
+ }
206
+ await Promise.all([...this.workers.values()].map(async (workerState) => {
207
+ await stopChild(workerState.child);
208
+ }));
209
+ this.workers.clear();
210
+ }
211
+
212
+ private spawnWorker(): void {
213
+ const workerId = crypto.randomUUID();
214
+ const child = fork(daemonWorkerProcessPath(), [], {
215
+ cwd: process.cwd(),
216
+ execArgv: ["--import", "tsx"],
217
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
218
+ });
219
+ const workerState: WorkerState = {
220
+ workerId,
221
+ child,
222
+ task: null,
223
+ };
224
+ child.on("message", (message: WorkerMessage) => {
225
+ this.handleWorkerMessage(workerState, message);
226
+ });
227
+ child.on("error", (error: Error) => {
228
+ this.handleWorkerFailure(workerState, error);
229
+ });
230
+ child.on("exit", (code) => {
231
+ this.handleWorkerExit(workerState, code ?? 0);
232
+ });
233
+ this.workers.set(workerId, workerState);
234
+ this.dispatch();
235
+ }
236
+
237
+ private dispatch(): void {
238
+ for (const workerState of this.workers.values()) {
239
+ if (workerState.task !== null) continue;
240
+ const next = this.queue.shift();
241
+ if (next === undefined) {
242
+ return;
243
+ }
244
+ const activeTask: ActiveWorkerTask = {
245
+ ...next,
246
+ workerId: workerState.workerId,
247
+ };
248
+ workerState.task = activeTask;
249
+ workerState.child.send({
250
+ requestId: activeTask.requestId,
251
+ kind: activeTask.kind,
252
+ job: activeTask.job,
253
+ });
254
+ }
255
+ }
256
+
257
+ private handleWorkerMessage(workerState: WorkerState, message: WorkerMessage): void {
258
+ const task = workerState.task;
259
+ if (task?.requestId !== message.requestId) {
260
+ return;
261
+ }
262
+ workerState.task = null;
263
+ if (message.ok) {
264
+ this.completedTasks++;
265
+ if (task.kind === "monitor_tick") {
266
+ task.resolve(message.result as MonitorTickWorkerResult);
267
+ } else {
268
+ task.resolve(message.result as RepoToolWorkerResult);
269
+ }
270
+ } else {
271
+ this.failedTasks++;
272
+ task.reject(new Error(message.error));
273
+ }
274
+ this.dispatch();
275
+ }
276
+
277
+ private handleWorkerFailure(workerState: WorkerState, error: Error): void {
278
+ const task = workerState.task;
279
+ workerState.task = null;
280
+ if (task !== null) {
281
+ this.failedTasks++;
282
+ task.reject(error);
283
+ }
284
+ }
285
+
286
+ private handleWorkerExit(workerState: WorkerState, code: number): void {
287
+ const task = workerState.task;
288
+ workerState.task = null;
289
+ this.workers.delete(workerState.workerId);
290
+ if (task !== null) {
291
+ this.failedTasks++;
292
+ task.reject(new Error(`daemon worker exited with code ${String(code)}`));
293
+ }
294
+ if (!this.closing) {
295
+ this.spawnWorker();
296
+ }
297
+ }
298
+ }
299
+
300
+ async function stopChild(child: ChildProcess): Promise<void> {
301
+ if (child.exitCode !== null || child.signalCode !== null) {
302
+ return;
303
+ }
304
+ child.kill();
305
+ await new Promise<void>((resolve) => {
306
+ child.once("exit", () => {
307
+ resolve();
308
+ });
309
+ });
310
+ }