querysub 0.497.0 → 0.499.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.
@@ -0,0 +1,634 @@
1
+ import * as http from "http";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { spawn, ChildProcess } from "child_process";
6
+ import { nextId, timeInHour, timeInMinute } from "socket-function/src/misc";
7
+ import { runInfinitePollCallAtStart, runInSerial } from "socket-function/src/batching";
8
+ import { formatTime } from "socket-function/src/formatting/format";
9
+ import { SocketFunction } from "socket-function/SocketFunction";
10
+ import { isPublic } from "../../../config";
11
+ import { timeoutToUndefinedSilent } from "../../../errors";
12
+ import { getAllNodeIds } from "../../../-f-node-discovery/NodeDiscovery";
13
+ import { getControllerNodeId, NodeCapabilitiesController } from "../../../-g-core-values/NodeCapabilities";
14
+ import { MCPIndexedLogs } from "../IndexedLogs/MCPIndexedLogs";
15
+ import { AutoFixerControllerBase, setAutoFixerHandlers } from "./autoFixerController";
16
+ import { TicketServiceBase } from "./tickets";
17
+ import { Ticket, TicketComment, TicketPatchFile, TicketState } from "./ticketTypes";
18
+
19
+ const INVESTIGATION_TIMEOUT = timeInMinute * 20;
20
+ // "Files modified" check on tickets we already know are open.
21
+ const OPEN_TICKET_POLL_INTERVAL = timeInMinute * 30;
22
+ // Full listing, to discover tickets we've never seen (in case a change notification was missed).
23
+ const ALL_TICKETS_POLL_INTERVAL = timeInHour;
24
+ // Re-registering is cheap and the service loses its watcher set when it restarts.
25
+ const REGISTER_POLL_INTERVAL = timeInMinute * 5;
26
+ const NODE_INFO_TIMEOUT_MS = 5000;
27
+ const TOOL_SERVER_PORT = 4517;
28
+ const MAX_TOOL_OUTPUT_STORED = 20_000;
29
+ const MAX_PRIOR_COMMENTS_PROMPT_CHARS = 30_000;
30
+ const AUTOFIXER_AUTHOR = "autofixer";
31
+
32
+ const PROTOCOL_VERSION = "2025-03-26";
33
+ const SERVER_INFO = { name: "querysub-autofixer", version: "0.1.0" };
34
+
35
+ const CLAUDE_ALLOWED_TOOLS = [
36
+ "Read",
37
+ "Glob",
38
+ "Grep",
39
+ "mcp__autofixer__searchLogs",
40
+ "mcp__autofixer__listNodes",
41
+ "mcp__autofixer__addComment",
42
+ "mcp__autofixer__addPatch",
43
+ "mcp__autofixer__setTicketState",
44
+ ];
45
+
46
+ async function ticketService() {
47
+ let controllerNodeId = await getControllerNodeId(TicketServiceBase, !isPublic());
48
+ if (!controllerNodeId) {
49
+ throw new Error(`Could not find node exposing controller TicketServiceBase. Is \`yarn error-watch\` running?`);
50
+ }
51
+ return TicketServiceBase.nodes[controllerNodeId];
52
+ }
53
+
54
+ // The ticket the current claude run is bound to. Runs are serialized, so a module-level value is safe.
55
+ let currentTicketId: string | undefined = undefined;
56
+ // Set when the AI calls setTicketState during a run, so we know not to auto-transition afterwards.
57
+ let stateChangedDuringRun = false;
58
+ // Set when the AI adds a patch during a run.
59
+ let patchAddedDuringRun = false;
60
+
61
+ let knownOpenTicketIds = new Set<string>();
62
+
63
+ const processTicket = runInSerial(async (ticketId: string): Promise<void> => {
64
+ try {
65
+ let service = await ticketService();
66
+ let ticket = await service.getTicket(ticketId);
67
+ if (!ticket) {
68
+ knownOpenTicketIds.delete(ticketId);
69
+ return;
70
+ }
71
+ if (ticket.state !== "investigation") {
72
+ knownOpenTicketIds.delete(ticketId);
73
+ return;
74
+ }
75
+ knownOpenTicketIds.add(ticketId);
76
+ await runInvestigation(ticket);
77
+ } catch (e) {
78
+ console.error(`AutoFixer failed to process ticket ${ticketId}:`, (e as Error).stack ?? e);
79
+ }
80
+ });
81
+
82
+ function queueTicket(ticketId: string) {
83
+ void processTicket(ticketId);
84
+ }
85
+
86
+ async function pollOpenTickets() {
87
+ for (let ticketId of Array.from(knownOpenTicketIds)) {
88
+ queueTicket(ticketId);
89
+ }
90
+ }
91
+
92
+ async function pollAllTickets() {
93
+ try {
94
+ let service = await ticketService();
95
+ let tickets = await service.getTickets();
96
+ for (let ticket of tickets) {
97
+ if (ticket.state === "investigation") {
98
+ knownOpenTicketIds.add(ticket.id);
99
+ queueTicket(ticket.id);
100
+ }
101
+ }
102
+ } catch (e) {
103
+ console.error(`AutoFixer failed to poll tickets:`, (e as Error).stack ?? e);
104
+ }
105
+ }
106
+
107
+ function onTicketsChanged(ticketIds: string[]) {
108
+ console.log(`AutoFixer received ticket change notification: ${ticketIds.join(", ")}`);
109
+ for (let ticketId of ticketIds) {
110
+ queueTicket(ticketId);
111
+ }
112
+ }
113
+
114
+ async function registerWithTicketService() {
115
+ try {
116
+ let service = await ticketService();
117
+ await service.registerAutoFixerSERVICE();
118
+ } catch (e) {
119
+ console.error(`AutoFixer failed to register with ticket service (will retry):`, (e as Error).stack ?? e);
120
+ }
121
+ }
122
+
123
+ async function applyPatch(ticketId: string, commentId: string): Promise<void> {
124
+ let service = await ticketService();
125
+ let ticket = await service.getTicket(ticketId);
126
+ if (!ticket) {
127
+ throw new Error(`Ticket ${ticketId} not found`);
128
+ }
129
+ let comment = ticket.comments.find(c => c.id === commentId);
130
+ if (!comment || comment.kind !== "patch" || !comment.patchFiles) {
131
+ throw new Error(`Comment ${commentId} is not a patch comment on ticket ${ticketId}`);
132
+ }
133
+
134
+ // Validate everything before writing anything, so a patch is all-or-nothing.
135
+ let writes: { fullPath: string; newContents: string }[] = [];
136
+ for (let patchFile of comment.patchFiles) {
137
+ let fullPath = path.resolve(process.cwd(), patchFile.file);
138
+ if (!patchFile.oldText) {
139
+ if (fs.existsSync(fullPath) && fs.readFileSync(fullPath, "utf8").trim()) {
140
+ throw new Error(`Patch wants to create ${patchFile.file}, but it already exists and is not empty`);
141
+ }
142
+ writes.push({ fullPath, newContents: patchFile.newText });
143
+ continue;
144
+ }
145
+ if (!fs.existsSync(fullPath)) {
146
+ throw new Error(`Patch targets ${patchFile.file}, which does not exist (cwd is ${process.cwd()})`);
147
+ }
148
+ let contents = fs.readFileSync(fullPath, "utf8");
149
+ let index = contents.indexOf(patchFile.oldText);
150
+ if (index === -1) {
151
+ throw new Error(`Patch oldText not found in ${patchFile.file}. The file may have changed since the patch was created.`);
152
+ }
153
+ let newContents = contents.slice(0, index) + patchFile.newText + contents.slice(index + patchFile.oldText.length);
154
+ writes.push({ fullPath, newContents });
155
+ }
156
+ for (let write of writes) {
157
+ fs.mkdirSync(path.dirname(write.fullPath), { recursive: true });
158
+ fs.writeFileSync(write.fullPath, write.newContents);
159
+ console.log(`AutoFixer applied patch to ${write.fullPath}`);
160
+ }
161
+ }
162
+
163
+ async function addTicketComment(ticketId: string, comment: Omit<TicketComment, "id" | "time" | "author">) {
164
+ let service = await ticketService();
165
+ await service.addComment(ticketId, {
166
+ id: nextId(),
167
+ time: Date.now(),
168
+ author: AUTOFIXER_AUTHOR,
169
+ ...comment,
170
+ });
171
+ }
172
+
173
+ // ==================== MCP tool server (what the claude process talks to) ====================
174
+
175
+ const TOOL_DEFS = [
176
+ {
177
+ name: "searchLogs",
178
+ description: `Search the indexed production logs (across log/info/warn/error) on one machine for rows matching a query, projected to the requested columns.
179
+
180
+ Returns { allColumns, results, files: { <loggerName>: {total, scanned} } }. If a \`limitHit\` field is present, results were truncated by the limit.
181
+
182
+ Query syntax (case-insensitive substring match by default):
183
+ | — OR. \`cat|dog\` matches if either substring is present.
184
+ & — AND. \`error&timeout\` matches if both are present (in any order, anywhere).
185
+ * — ordered wildcard. \`cat*ate\` matches "cat ate my bird" but NOT "cate".
186
+ No negation is supported.`,
187
+ inputSchema: {
188
+ type: "object",
189
+ properties: {
190
+ query: { type: "string" },
191
+ machine: { type: "string", description: "machineId, or \"local\" for the machine running this server" },
192
+ startTime: { type: ["number", "string"], description: "epoch ms or any string Date can parse (no-tz strings are local time)" },
193
+ endTime: { type: ["number", "string"], description: "epoch ms or any string Date can parse; must be at least 1 minute in the past" },
194
+ direction: { type: "string", enum: ["fromStart", "fromEnd"] },
195
+ columns: { type: "array", items: { type: "string" }, description: "Which fields to project onto each row. Use [] to get just metadata." },
196
+ limit: { type: "number", default: 100 },
197
+ logTypes: { type: "string", description: "Optional pipe-separated list restricting which log streams to scan. Allowed values: log, info, warn, error." },
198
+ },
199
+ required: ["query", "machine", "startTime", "endTime", "direction", "columns"],
200
+ },
201
+ },
202
+ {
203
+ name: "listNodes",
204
+ description: `List every node in the Querysub cluster, with each node's process entry point. Returns an array of { nodeId, entryPoint }.`,
205
+ inputSchema: { type: "object", properties: {} },
206
+ },
207
+ {
208
+ name: "addComment",
209
+ description: `Add a text comment to the ticket you are investigating. Use this to record your diagnosis before proposing any fixes.`,
210
+ inputSchema: {
211
+ type: "object",
212
+ properties: {
213
+ text: { type: "string" },
214
+ },
215
+ required: ["text"],
216
+ },
217
+ },
218
+ {
219
+ name: "addPatch",
220
+ description: `Propose a code patch on the ticket. The patch is NOT applied automatically — a human reviews it and applies or rejects it. Each file entry replaces the first exact occurrence of oldText with newText. oldText must be copied exactly from the current file contents and should be unique within the file. An empty oldText creates a new file with newText as its contents.`,
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ text: { type: "string", description: "Explanation of what the patch does and why" },
225
+ files: {
226
+ type: "array",
227
+ items: {
228
+ type: "object",
229
+ properties: {
230
+ file: { type: "string", description: "Path relative to the repository root" },
231
+ oldText: { type: "string" },
232
+ newText: { type: "string" },
233
+ },
234
+ required: ["file", "oldText", "newText"],
235
+ },
236
+ },
237
+ },
238
+ required: ["text", "files"],
239
+ },
240
+ },
241
+ {
242
+ name: "setTicketState",
243
+ description: `Set the state of the ticket you are investigating. Use "code-change" once you have proposed patches, or "not-a-bug" if the error should simply be ignored.`,
244
+ inputSchema: {
245
+ type: "object",
246
+ properties: {
247
+ state: { type: "string", enum: ["code-change", "not-a-bug"] },
248
+ },
249
+ required: ["state"],
250
+ },
251
+ },
252
+ ];
253
+
254
+ type JsonRpcRequest = {
255
+ jsonrpc: "2.0";
256
+ id?: number | string | null;
257
+ method: string;
258
+ params?: unknown;
259
+ };
260
+
261
+ type JsonRpcResponse = {
262
+ jsonrpc: "2.0";
263
+ id: number | string | null;
264
+ result?: unknown;
265
+ error?: { code: number; message: string; data?: unknown };
266
+ };
267
+
268
+ function errorResponse(id: number | string | null, code: number, message: string): JsonRpcResponse {
269
+ return { jsonrpc: "2.0", id, error: { code, message } };
270
+ }
271
+
272
+ async function getNodeInfos() {
273
+ let nodes = await getAllNodeIds();
274
+ return Promise.all(
275
+ nodes.map(async nodeId => {
276
+ let metadata = await timeoutToUndefinedSilent(NODE_INFO_TIMEOUT_MS, NodeCapabilitiesController.nodes[nodeId].getMetadata());
277
+ return { nodeId, entryPoint: metadata?.entryPoint };
278
+ }),
279
+ );
280
+ }
281
+
282
+ let mcpLogs = new MCPIndexedLogs();
283
+
284
+ async function callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
285
+ let ticketId = currentTicketId;
286
+ if (!ticketId) {
287
+ throw new Error(`No ticket is currently being investigated`);
288
+ }
289
+
290
+ if (toolName === "addComment") {
291
+ let text = String(args.text ?? "").trim();
292
+ if (!text) {
293
+ throw new Error(`addComment requires non-empty text`);
294
+ }
295
+ await addTicketComment(ticketId, { kind: "text", text });
296
+ return { ok: true };
297
+ }
298
+ if (toolName === "addPatch") {
299
+ let text = String(args.text ?? "").trim();
300
+ let files = args.files as TicketPatchFile[] | undefined;
301
+ if (!files || !Array.isArray(files) || files.length === 0) {
302
+ throw new Error(`addPatch requires at least one file entry`);
303
+ }
304
+ for (let file of files) {
305
+ if (!file.file || typeof file.newText !== "string" || typeof file.oldText !== "string") {
306
+ throw new Error(`Each patch file entry requires file, oldText, and newText`);
307
+ }
308
+ }
309
+ await addTicketComment(ticketId, {
310
+ kind: "patch",
311
+ text,
312
+ patchFiles: files,
313
+ patchStatus: "pending",
314
+ });
315
+ patchAddedDuringRun = true;
316
+ return { ok: true };
317
+ }
318
+ // Remaining tools don't create a visible comment of their own, so record the call and its output chronologically in the ticket.
319
+ let startTime = Date.now();
320
+ let result: unknown;
321
+ let errorText: string | undefined = undefined;
322
+ try {
323
+ if (toolName === "searchLogs") {
324
+ result = await mcpLogs.search(args as Parameters<MCPIndexedLogs["search"]>[0]);
325
+ } else if (toolName === "listNodes") {
326
+ result = await getNodeInfos();
327
+ } else if (toolName === "setTicketState") {
328
+ let state = String(args.state ?? "") as TicketState;
329
+ if (state !== "code-change" && state !== "not-a-bug") {
330
+ throw new Error(`setTicketState only allows "code-change" or "not-a-bug"`);
331
+ }
332
+ let service = await ticketService();
333
+ await service.setTicketState(ticketId, state);
334
+ stateChangedDuringRun = true;
335
+ result = { ok: true };
336
+ } else {
337
+ throw new Error(`Unknown tool ${toolName}`);
338
+ }
339
+ } catch (e) {
340
+ errorText = (e as Error).stack ?? String(e);
341
+ }
342
+ let outputText = errorText !== undefined && `ERROR: ${errorText}` || JSON.stringify(result);
343
+ if (outputText.length > MAX_TOOL_OUTPUT_STORED) {
344
+ outputText = outputText.slice(0, MAX_TOOL_OUTPUT_STORED) + `... (truncated, ${outputText.length} chars total)`;
345
+ }
346
+ try {
347
+ await addTicketComment(ticketId, {
348
+ kind: "tool-call",
349
+ text: "",
350
+ toolName,
351
+ toolInput: JSON.stringify(args),
352
+ toolOutput: outputText,
353
+ toolDurationMs: Date.now() - startTime,
354
+ });
355
+ } catch (e) {
356
+ console.error(`Failed to record tool call in ticket ${ticketId}:`, (e as Error).stack ?? e);
357
+ }
358
+ if (errorText !== undefined) {
359
+ throw new Error(errorText);
360
+ }
361
+ return result;
362
+ }
363
+
364
+ async function dispatch(method: string, params: unknown): Promise<unknown> {
365
+ if (method === "initialize") {
366
+ return {
367
+ protocolVersion: PROTOCOL_VERSION,
368
+ capabilities: { tools: {} },
369
+ serverInfo: SERVER_INFO,
370
+ };
371
+ }
372
+ if (method === "tools/list") {
373
+ return { tools: TOOL_DEFS };
374
+ }
375
+ if (method === "tools/call") {
376
+ let p = (params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
377
+ let result = await callTool(p.name ?? "", p.arguments ?? {});
378
+ return {
379
+ content: [{ type: "text", text: JSON.stringify(result) }],
380
+ };
381
+ }
382
+ if (method === "ping") {
383
+ return {};
384
+ }
385
+ if (method.startsWith("notifications/")) {
386
+ return {};
387
+ }
388
+ throw new Error(`Unknown method ${method}`);
389
+ }
390
+
391
+ async function handleJsonRpc(body: string): Promise<JsonRpcResponse | undefined> {
392
+ let req: JsonRpcRequest;
393
+ try {
394
+ req = JSON.parse(body);
395
+ } catch (e) {
396
+ return errorResponse(null, -32700, `Parse error: ${(e as Error).message}`);
397
+ }
398
+ if (req.jsonrpc !== "2.0" || typeof req.method !== "string") {
399
+ return errorResponse(req.id ?? null, -32600, `Invalid request`);
400
+ }
401
+ let isNotification = req.id === undefined;
402
+ try {
403
+ let result = await dispatch(req.method, req.params);
404
+ if (isNotification) return undefined;
405
+ return { jsonrpc: "2.0", id: req.id ?? null, result };
406
+ } catch (e) {
407
+ console.error(`AutoFixer tool server error in ${req.method}:`, (e as Error).stack ?? e);
408
+ if (isNotification) return undefined;
409
+ return errorResponse(req.id ?? null, -32000, (e as Error).message ?? String(e));
410
+ }
411
+ }
412
+
413
+ async function startToolServer(): Promise<void> {
414
+ let server = http.createServer((req, res) => {
415
+ if (req.method !== "POST") {
416
+ res.statusCode = 405;
417
+ res.end();
418
+ return;
419
+ }
420
+ let chunks: Buffer[] = [];
421
+ req.on("data", c => chunks.push(c));
422
+ req.on("end", async () => {
423
+ let body = Buffer.concat(chunks).toString("utf8");
424
+ let response = await handleJsonRpc(body);
425
+ if (response === undefined) {
426
+ res.statusCode = 204;
427
+ res.end();
428
+ return;
429
+ }
430
+ res.setHeader("content-type", "application/json");
431
+ res.end(JSON.stringify(response));
432
+ });
433
+ req.on("error", e => {
434
+ console.error(`AutoFixer tool server request error:`, (e as Error).stack ?? e);
435
+ res.statusCode = 500;
436
+ res.end();
437
+ });
438
+ });
439
+ await new Promise<void>((resolve, reject) => {
440
+ server.once("error", reject);
441
+ server.listen(TOOL_SERVER_PORT, "127.0.0.1", () => resolve());
442
+ });
443
+ console.log(`AutoFixer tool server listening on http://127.0.0.1:${TOOL_SERVER_PORT}`);
444
+ }
445
+
446
+ // ==================== Investigation runs ====================
447
+
448
+ function buildPrompt(ticket: Ticket): string {
449
+ let priorComments: string[] = [];
450
+ for (let comment of ticket.comments) {
451
+ let time = new Date(comment.time).toISOString();
452
+ if (comment.kind === "text") {
453
+ priorComments.push(`[${time}] ${comment.author}: ${comment.text}`);
454
+ } else if (comment.kind === "patch") {
455
+ let fileList = (comment.patchFiles ?? []).map(f => f.file).join(", ");
456
+ priorComments.push(`[${time}] ${comment.author} proposed a patch (status: ${comment.patchStatus}) touching ${fileList}: ${comment.text}`);
457
+ } else if (comment.kind === "tool-call") {
458
+ priorComments.push(`[${time}] tool ${comment.toolName}(${comment.toolInput ?? ""}) => ${(comment.toolOutput ?? "").slice(0, 500)}`);
459
+ }
460
+ }
461
+ let priorCommentsText = priorComments.join("\n");
462
+ if (priorCommentsText.length > MAX_PRIOR_COMMENTS_PROMPT_CHARS) {
463
+ priorCommentsText = `(older comments omitted)\n` + priorCommentsText.slice(-MAX_PRIOR_COMMENTS_PROMPT_CHARS);
464
+ }
465
+
466
+ return `You are an automated bug investigator ("autofixer") working on a ticket created from a production error.
467
+
468
+ The repository is at ${process.cwd()}. Use Read/Glob/Grep to read the code, and the mcp__autofixer__searchLogs / mcp__autofixer__listNodes tools to search the production logs.
469
+
470
+ Work in two phases:
471
+
472
+ PHASE 1 — INVESTIGATION. Figure out what is wrong. Do log searches and read the code. Do NOT think about the fix yet — focus only on identifying the problem. Conclude this phase by calling mcp__autofixer__addComment with your diagnosis. The diagnosis must be one of:
473
+ - "This code is broken here, and this is why it is broken" (name the file and the mechanism), or
474
+ - "This error is not important and we should ignore it, and this is why" (e.g. a remote TCP client disconnecting is not an error — remote clients are supposed to disappear).
475
+
476
+ PHASE 2 — FIX. Only after you have added your diagnosis comment, decide on the fix and propose it with mcp__autofixer__addPatch. The kinds of fixes are:
477
+ 1. Downgrading logging: when the "error" is not actually an error, patch the logging call site to downgrade it from an error to a warning or a plain log, so it stops being reported.
478
+ 2. Actually fixing the broken code.
479
+ 3. Gathering more information: if you could NOT determine the root cause from the available logs, propose patches that ADD logging statements to the relevant code paths so the next investigation has the information it needs.
480
+ Each patch file entry is { file, oldText, newText }: oldText must be copied exactly from the current file contents and should be unique within the file; it is replaced with newText. An empty oldText creates a new file. Keep patches minimal and follow the style of the surrounding code.
481
+
482
+ When you are done, call mcp__autofixer__setTicketState with "code-change" if you proposed patches, or "not-a-bug" if the error should simply be ignored without any code change. Do NOT edit files directly — only propose changes through mcp__autofixer__addPatch.
483
+
484
+ ==== TICKET ====
485
+ Title: ${ticket.title}
486
+ Created: ${new Date(ticket.createdTime).toISOString()}
487
+ ${ticket.suppressionPattern && `Matched suppression pattern: ${ticket.suppressionPattern}\n` || ""}
488
+ ==== ERROR LOG ENTRY (full JSON) ====
489
+ ${JSON.stringify(ticket.errorDatum, undefined, 2)}
490
+
491
+ ==== COMMENTS SO FAR (chronological) ====
492
+ ${priorCommentsText || "(none)"}
493
+ `;
494
+ }
495
+
496
+ function quoteArgForShell(arg: string): string {
497
+ if (!/[\s"^&|<>()%!;'$`\\]/.test(arg)) return arg;
498
+ return `"${arg.replace(/"/g, "\\\"")}"`;
499
+ }
500
+
501
+ function killChildTree(child: ChildProcess) {
502
+ if (!child.pid) return;
503
+ if (process.platform === "win32") {
504
+ spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"]);
505
+ } else {
506
+ child.kill("SIGKILL");
507
+ }
508
+ }
509
+
510
+ async function runClaude(prompt: string): Promise<{ timedOut: boolean; exitCode: number | undefined }> {
511
+ let mcpConfigPath = path.join(os.tmpdir(), `autofixer-mcp-${process.pid}.json`);
512
+ fs.writeFileSync(mcpConfigPath, JSON.stringify({
513
+ mcpServers: {
514
+ autofixer: {
515
+ type: "http",
516
+ url: `http://127.0.0.1:${TOOL_SERVER_PORT}/`,
517
+ },
518
+ },
519
+ }));
520
+
521
+ let args = [
522
+ "-p",
523
+ "--mcp-config", mcpConfigPath,
524
+ "--strict-mcp-config",
525
+ "--allowedTools", CLAUDE_ALLOWED_TOOLS.join(","),
526
+ ];
527
+
528
+ let child: ChildProcess;
529
+ if (process.platform === "win32") {
530
+ // claude is a .cmd shim on Windows, which spawn can only run through a shell. The shell does no escaping, so we quote the command line ourselves.
531
+ let commandLine = ["claude", ...args].map(quoteArgForShell).join(" ");
532
+ child = spawn(commandLine, { shell: true, stdio: ["pipe", "pipe", "pipe"] });
533
+ } else {
534
+ child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
535
+ }
536
+
537
+ child.stdin!.write(prompt);
538
+ child.stdin!.end();
539
+
540
+ function forwardLines(stream: NodeJS.ReadableStream, prefix: string) {
541
+ let pending = "";
542
+ stream.on("data", (chunk: Buffer) => {
543
+ pending += chunk.toString("utf8");
544
+ let lines = pending.split("\n");
545
+ pending = lines.pop() ?? "";
546
+ for (let line of lines) {
547
+ console.log(`${prefix} ${line}`);
548
+ }
549
+ });
550
+ stream.on("end", () => {
551
+ if (pending) {
552
+ console.log(`${prefix} ${pending}`);
553
+ }
554
+ });
555
+ }
556
+ forwardLines(child.stdout!, "[claude]");
557
+ forwardLines(child.stderr!, "[claude:err]");
558
+
559
+ let timedOut = false;
560
+ let timeout = setTimeout(() => {
561
+ timedOut = true;
562
+ console.error(`Claude investigation exceeded ${formatTime(INVESTIGATION_TIMEOUT)}, killing it`);
563
+ killChildTree(child);
564
+ }, INVESTIGATION_TIMEOUT);
565
+
566
+ let exitCode = await new Promise<number | undefined>(resolve => {
567
+ child.on("error", e => {
568
+ console.error(`Failed to spawn claude:`, (e as Error).stack ?? e);
569
+ resolve(undefined);
570
+ });
571
+ child.on("exit", code => resolve(code ?? undefined));
572
+ });
573
+ clearTimeout(timeout);
574
+ return { timedOut, exitCode };
575
+ }
576
+
577
+ async function runInvestigation(ticket: Ticket): Promise<void> {
578
+ console.log(`AutoFixer starting investigation of ticket ${ticket.id}: ${ticket.title}`);
579
+ let service = await ticketService();
580
+
581
+ currentTicketId = ticket.id;
582
+ stateChangedDuringRun = false;
583
+ patchAddedDuringRun = false;
584
+ try {
585
+ await addTicketComment(ticket.id, {
586
+ kind: "text",
587
+ text: `Starting automated investigation (timeout ${formatTime(INVESTIGATION_TIMEOUT)}).`,
588
+ });
589
+
590
+ let { timedOut, exitCode } = await runClaude(buildPrompt(ticket));
591
+
592
+ if (timedOut) {
593
+ await addTicketComment(ticket.id, {
594
+ kind: "text",
595
+ text: `Automated investigation timed out after ${formatTime(INVESTIGATION_TIMEOUT)} and was stopped.`,
596
+ });
597
+ await service.setTicketState(ticket.id, "timed-out");
598
+ return;
599
+ }
600
+
601
+ let updated = await service.getTicket(ticket.id);
602
+ if (!updated || updated.state !== "investigation" || stateChangedDuringRun) {
603
+ console.log(`AutoFixer finished investigation of ticket ${ticket.id} (state: ${updated?.state})`);
604
+ return;
605
+ }
606
+ if (patchAddedDuringRun) {
607
+ await addTicketComment(ticket.id, {
608
+ kind: "text",
609
+ text: `Automated investigation proposed patches but did not set the ticket state; moving the ticket to code-change.`,
610
+ });
611
+ await service.setTicketState(ticket.id, "code-change");
612
+ } else {
613
+ await addTicketComment(ticket.id, {
614
+ kind: "text",
615
+ text: `Automated investigation ended (claude exit code ${exitCode}) without a diagnosis, patches, or a state change. Marking the ticket timed-out so it is not retried automatically — set it back to investigation to retry.`,
616
+ });
617
+ await service.setTicketState(ticket.id, "timed-out");
618
+ }
619
+ } finally {
620
+ currentTicketId = undefined;
621
+ }
622
+ }
623
+
624
+ export async function runAutoFixer(): Promise<void> {
625
+ setAutoFixerHandlers({ onTicketsChanged, applyPatch });
626
+ SocketFunction.expose(AutoFixerControllerBase);
627
+ await startToolServer();
628
+
629
+ void runInfinitePollCallAtStart(REGISTER_POLL_INTERVAL, registerWithTicketService);
630
+ void runInfinitePollCallAtStart(ALL_TICKETS_POLL_INTERVAL, pollAllTickets);
631
+ void runInfinitePollCallAtStart(OPEN_TICKET_POLL_INTERVAL, pollOpenTickets);
632
+
633
+ console.log(`AutoFixer running. Watching for tickets in the investigation state (cwd: ${process.cwd()}).`);
634
+ }
@@ -0,0 +1,41 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { assertIsManagementUser } from "../../managementPages";
3
+
4
+ // The AutoFixerController is registered in every process (so ticket code can reference it), but only does anything in the autofixer process, which installs handlers via setAutoFixerHandlers and exposes the controller.
5
+ export type AutoFixerHandlers = {
6
+ onTicketsChanged(ticketIds: string[]): void;
7
+ applyPatch(ticketId: string, commentId: string): Promise<void>;
8
+ };
9
+
10
+ let handlers: AutoFixerHandlers | undefined = undefined;
11
+ export function setAutoFixerHandlers(newHandlers: AutoFixerHandlers) {
12
+ handlers = newHandlers;
13
+ }
14
+
15
+ class AutoFixerController {
16
+ public async onTicketsChanged(ticketIds: string[]) {
17
+ handlers?.onTicketsChanged(ticketIds);
18
+ }
19
+
20
+ public async applyPatch(ticketId: string, commentId: string) {
21
+ if (!handlers) {
22
+ throw new Error(`AutoFixer is not running on this node, so patches cannot be applied here`);
23
+ }
24
+ await handlers.applyPatch(ticketId, commentId);
25
+ }
26
+ }
27
+
28
+ export const AutoFixerControllerBase = SocketFunction.register(
29
+ "AutoFixerController-019c9cae-8333-7708-a4e7-500f5fc23182",
30
+ new AutoFixerController(),
31
+ () => ({
32
+ onTicketsChanged: {},
33
+ applyPatch: {},
34
+ }),
35
+ () => ({
36
+ hooks: [assertIsManagementUser],
37
+ }),
38
+ {
39
+ noAutoExpose: true,
40
+ }
41
+ );