querysub 0.680.0 → 0.682.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.
@@ -3,6 +3,7 @@ import * as fs from "fs";
3
3
  import * as os from "os";
4
4
  import * as path from "path";
5
5
  import { spawn, ChildProcess } from "child_process";
6
+ import { lazy } from "socket-function/src/caching";
6
7
  import { nextId, timeInHour, timeInMinute } from "socket-function/src/misc";
7
8
  import { runInfinitePollCallAtStart, runInSerial } from "socket-function/src/batching";
8
9
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
@@ -16,7 +17,7 @@ import { getControllerNodeId, NodeCapabilitiesController } from "../../../-g-cor
16
17
  import { MCPIndexedLogs } from "../IndexedLogs/MCPIndexedLogs";
17
18
  import { AutoFixerControllerBase, setAutoFixerHandlers } from "./autoFixerController";
18
19
  import { TicketServiceBase } from "./tickets";
19
- import { Ticket, TicketComment, TicketPatchFile, TicketState } from "./ticketTypes";
20
+ import { AutoFixerRunStatus, Ticket, TicketComment, TicketPatchFile, TicketState } from "./ticketTypes";
20
21
 
21
22
  const INVESTIGATION_TIMEOUT = timeInMinute * 60;
22
23
  // "Files modified" check on tickets we already know are open.
@@ -59,6 +60,13 @@ let currentTicketId: string | undefined = undefined;
59
60
  let currentTicketTitle: string | undefined = undefined;
60
61
  let currentTicketStartTime = 0;
61
62
 
63
+ // Model selected per ticket (from the ticket page dropdown); absent means claude's default.
64
+ let ticketModelOverrides = new Map<string, string>();
65
+ let currentRunRequestedModel: string | undefined = undefined;
66
+ // From claude's stream-json init event, which names the session's actual model.
67
+ let currentRunResolvedModel: string | undefined = undefined;
68
+ let defaultClaudeModel: string | undefined = undefined;
69
+
62
70
  // inputTokens is uncached input only — cache reads/writes are tracked separately, otherwise cache reads massively inflate the input number.
63
71
  type TokenUsage = {
64
72
  inputTokens: number;
@@ -101,7 +109,20 @@ let patchAddedDuringRun = false;
101
109
 
102
110
  let knownOpenTicketIds = new Set<string>();
103
111
 
112
+ // Tickets queued behind the current run (runs are serialized), for status reporting.
113
+ let pendingTicketIds = new Set<string>();
114
+ // Manually triggered runs skip the "must be in investigation state" check — the user explicitly asked for a run.
115
+ let manualRunTicketIds = new Set<string>();
116
+ // A stop lets the in-flight step finish, then ends the run (or skips the run entirely if it hasn't started yet).
117
+ let stopRequestedTicketIds = new Set<string>();
118
+
104
119
  const processTicket = runInSerial(async (ticketId: string): Promise<void> => {
120
+ pendingTicketIds.delete(ticketId);
121
+ let manual = manualRunTicketIds.delete(ticketId);
122
+ if (stopRequestedTicketIds.delete(ticketId)) {
123
+ console.log(`AutoFixer skipping queued investigation of ticket ${ticketId} because a stop was requested before it started`);
124
+ return;
125
+ }
105
126
  try {
106
127
  let service = await ticketService();
107
128
  let ticket = await service.getTicket(ticketId);
@@ -111,9 +132,12 @@ const processTicket = runInSerial(async (ticketId: string): Promise<void> => {
111
132
  }
112
133
  if (ticket.state !== "investigation") {
113
134
  knownOpenTicketIds.delete(ticketId);
114
- return;
135
+ if (!manual) {
136
+ return;
137
+ }
138
+ } else {
139
+ knownOpenTicketIds.add(ticketId);
115
140
  }
116
- knownOpenTicketIds.add(ticketId);
117
141
  await runInvestigation(ticket);
118
142
  } catch (e) {
119
143
  console.error(`AutoFixer failed to process ticket ${ticketId}:`, (e as Error).stack ?? e);
@@ -121,9 +145,59 @@ const processTicket = runInSerial(async (ticketId: string): Promise<void> => {
121
145
  });
122
146
 
123
147
  function queueTicket(ticketId: string) {
148
+ pendingTicketIds.add(ticketId);
124
149
  void processTicket(ticketId);
125
150
  }
126
151
 
152
+ export function getAutoFixerRunStatus(): AutoFixerRunStatus {
153
+ return {
154
+ runningTicketId: currentTicketId,
155
+ runningTicketTitle: currentTicketTitle,
156
+ runStartTime: currentTicketId && currentTicketStartTime || undefined,
157
+ runningModel: currentTicketId && (currentRunResolvedModel ?? currentRunRequestedModel) || undefined,
158
+ defaultModel: defaultClaudeModel,
159
+ stopRequested: currentTicketId && stopRequestedTicketIds.has(currentTicketId) || undefined,
160
+ queuedTicketIds: Array.from(pendingTicketIds).filter(id => id !== currentTicketId),
161
+ };
162
+ }
163
+
164
+ export async function startTicketInvestigation(ticketId: string, model?: string): Promise<void> {
165
+ // The model becomes a command-line argument, so restrict it even though the dropdown only sends known values.
166
+ if (model && !/^[a-zA-Z0-9._-]+$/.test(model)) {
167
+ throw new Error(`Invalid model name: ${model}`);
168
+ }
169
+ await ensureAutoFixerBase();
170
+ await registerWithTicketService();
171
+ let service = await ticketService();
172
+ let ticket = await service.getTicket(ticketId);
173
+ if (!ticket) {
174
+ throw new Error(`Ticket ${ticketId} not found`);
175
+ }
176
+ if (currentTicketId === ticketId) {
177
+ throw new Error(`An AI investigation is already running for this ticket`);
178
+ }
179
+ if (pendingTicketIds.has(ticketId)) {
180
+ throw new Error(`An AI investigation is already queued for this ticket`);
181
+ }
182
+ console.log(green(`Manual AI investigation triggered for ticket ${ticketId} (model: ${model ?? "default"}): ${ticket.title}`));
183
+ stopRequestedTicketIds.delete(ticketId);
184
+ if (model) {
185
+ ticketModelOverrides.set(ticketId, model);
186
+ } else {
187
+ ticketModelOverrides.delete(ticketId);
188
+ }
189
+ manualRunTicketIds.add(ticketId);
190
+ queueTicket(ticketId);
191
+ }
192
+
193
+ export function requestStopTicketInvestigation(ticketId: string): void {
194
+ if (currentTicketId !== ticketId && !pendingTicketIds.has(ticketId)) {
195
+ return;
196
+ }
197
+ console.log(green(`Stop requested for AI investigation of ticket ${ticketId} (current: ${currentTicketId ?? "none"})`));
198
+ stopRequestedTicketIds.add(ticketId);
199
+ }
200
+
127
201
  async function pollOpenTickets() {
128
202
  for (let ticketId of Array.from(knownOpenTicketIds)) {
129
203
  queueTicket(ticketId);
@@ -145,7 +219,11 @@ async function pollAllTickets() {
145
219
  }
146
220
  }
147
221
 
222
+ // Only the dedicated autofixer process (yarn autofix) auto-processes every investigation ticket. Nodes that merely host manual runs ignore change notifications.
223
+ let autoProcessingEnabled = false;
224
+
148
225
  function onTicketsChanged(ticketIds: string[]) {
226
+ if (!autoProcessingEnabled) return;
149
227
  console.log(`AutoFixer received ticket change notification: ${ticketIds.join(", ")}`);
150
228
  for (let ticketId of ticketIds) {
151
229
  queueTicket(ticketId);
@@ -161,65 +239,83 @@ async function registerWithTicketService() {
161
239
  }
162
240
  }
163
241
 
164
- // Relative paths may belong to either the application repo or the querysub repo (a sibling / dependency) file paths are unique across the two, so whichever one actually has the file wins.
165
- function resolvePatchFilePath(file: string): { fullPath: string | undefined; candidates: string[] } {
166
- let candidates = Array.from(new Set([
167
- path.resolve(process.cwd(), file),
168
- path.resolve(QUERYSUB_ROOT, file),
169
- ]));
170
- for (let candidate of candidates) {
171
- if (fs.existsSync(candidate)) {
172
- return { fullPath: candidate, candidates };
242
+ // Resolves and validates every file entry without writing anything, accumulating the resulting file contents into `overlay` (fullPath -> contents). Reads go through the overlay first so several patches touching the same file compose in order. Throws with a detailed message when the patch cannot apply cleanly also used at addPatch time so the AI is told about mistakes immediately.
243
+ function preparePatchWrites(patchFiles: TicketPatchFile[], overlay: Map<string, string>): void {
244
+ // Relative paths may belong to either the application repo or the querysub repo (a sibling / dependency) — file paths are unique across the two, so whichever one actually has the file wins.
245
+ function readCurrent(candidates: string[]): { fullPath: string; contents: string } | undefined {
246
+ for (let candidate of candidates) {
247
+ let pending = overlay.get(candidate);
248
+ if (pending !== undefined) {
249
+ return { fullPath: candidate, contents: pending };
250
+ }
173
251
  }
252
+ for (let candidate of candidates) {
253
+ if (fs.existsSync(candidate)) {
254
+ return { fullPath: candidate, contents: fs.readFileSync(candidate, "utf8") };
255
+ }
256
+ }
257
+ return undefined;
174
258
  }
175
- return { fullPath: undefined, candidates };
176
- }
177
-
178
- // Resolves and validates every file entry without writing anything (so a patch is all-or-nothing), returning the writes to perform. Throws with a detailed message when the patch cannot apply cleanly — also used at addPatch time so the AI is told about mistakes immediately.
179
- function preparePatchWrites(patchFiles: TicketPatchFile[]): { fullPath: string; newContents: string }[] {
180
- let writes: { fullPath: string; newContents: string }[] = [];
181
259
  for (let patchFile of patchFiles) {
182
- let { fullPath, candidates } = resolvePatchFilePath(patchFile.file);
260
+ let candidates = Array.from(new Set([
261
+ path.resolve(process.cwd(), patchFile.file),
262
+ path.resolve(QUERYSUB_ROOT, patchFile.file),
263
+ ]));
264
+ let current = readCurrent(candidates);
183
265
  if (!patchFile.oldText) {
184
- if (fullPath && fs.readFileSync(fullPath, "utf8").trim()) {
185
- throw new Error(`Patch wants to create ${patchFile.file}, but it already exists at ${fullPath} and is not empty`);
266
+ if (current && current.contents.trim() && current.contents !== patchFile.newText) {
267
+ throw new Error(`Patch wants to create ${patchFile.file}, but it already exists at ${current.fullPath} and is not empty`);
186
268
  }
187
- writes.push({ fullPath: fullPath ?? candidates[0], newContents: patchFile.newText });
269
+ overlay.set(current?.fullPath ?? candidates[0], patchFile.newText);
188
270
  continue;
189
271
  }
190
- if (!fullPath) {
272
+ if (!current) {
191
273
  throw new Error(`Patch targets ${patchFile.file}, which does not exist. Tried: ${candidates.join(", ")}`);
192
274
  }
193
- let contents = fs.readFileSync(fullPath, "utf8");
275
+ let { fullPath, contents } = current;
194
276
  let index = contents.indexOf(patchFile.oldText);
195
277
  if (index === -1) {
278
+ // Re-applying is allowed, so an entry whose replacement is already present is a no-op instead of an error.
279
+ if (contents.includes(patchFile.newText)) continue;
196
280
  throw new Error(`Patch oldText not found in ${fullPath}. oldText must be copied EXACTLY from the current file contents (check whitespace/indentation).`);
197
281
  }
198
282
  if (contents.indexOf(patchFile.oldText, index + 1) !== -1) {
199
283
  throw new Error(`Patch oldText appears more than once in ${fullPath}, so the replacement is ambiguous. Include more surrounding context to make it unique.`);
200
284
  }
201
- let newContents = contents.slice(0, index) + patchFile.newText + contents.slice(index + patchFile.oldText.length);
202
- writes.push({ fullPath, newContents });
285
+ overlay.set(fullPath, contents.slice(0, index) + patchFile.newText + contents.slice(index + patchFile.oldText.length));
203
286
  }
204
- return writes;
205
287
  }
206
288
 
207
- async function applyPatch(ticketId: string, commentId: string): Promise<void> {
289
+ // Applies a batch of patch comments as one operation: validate ALL of them first (nothing is written if any fails), then persist the applied statuses, and only then write the files. The file writes go last because they can hot-reload this very process (and the page the user is on) everything else must already be durable by then.
290
+ export async function applyPatches(ticketId: string, commentIds: string[]): Promise<void> {
208
291
  let service = await ticketService();
209
292
  let ticket = await service.getTicket(ticketId);
210
293
  if (!ticket) {
211
294
  throw new Error(`Ticket ${ticketId} not found`);
212
295
  }
213
- let comment = ticket.comments.find(c => c.id === commentId);
214
- if (!comment || comment.kind !== "patch" || !comment.patchFiles) {
215
- throw new Error(`Comment ${commentId} is not a patch comment on ticket ${ticketId}`);
296
+ let comments = commentIds.map(commentId => {
297
+ let comment = ticket!.comments.find(c => c.id === commentId);
298
+ if (!comment || comment.kind !== "patch" || !comment.patchFiles) {
299
+ throw new Error(`Comment ${commentId} is not a patch comment on ticket ${ticketId}`);
300
+ }
301
+ return comment;
302
+ });
303
+
304
+ let overlay = new Map<string, string>();
305
+ for (let comment of comments) {
306
+ try {
307
+ preparePatchWrites(comment.patchFiles!, overlay);
308
+ } catch (e) {
309
+ throw new Error(`Patch ${comment.id} failed validation — NO patches were applied. ${(e as Error).message}`);
310
+ }
216
311
  }
217
312
 
218
- let writes = preparePatchWrites(comment.patchFiles);
219
- for (let write of writes) {
220
- fs.mkdirSync(path.dirname(write.fullPath), { recursive: true });
221
- fs.writeFileSync(write.fullPath, write.newContents);
222
- console.log(`AutoFixer applied patch to ${write.fullPath}`);
313
+ await service.setPatchStatuses(ticketId, commentIds, "applied");
314
+
315
+ for (let [fullPath, contents] of overlay) {
316
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
317
+ fs.writeFileSync(fullPath, contents);
318
+ console.log(`AutoFixer applied patch to ${fullPath}`);
223
319
  }
224
320
  }
225
321
 
@@ -382,7 +478,7 @@ async function callTool(toolName: string, args: Record<string, unknown>): Promis
382
478
  }
383
479
  }
384
480
  try {
385
- preparePatchWrites(files);
481
+ preparePatchWrites(files, new Map());
386
482
  } catch (e) {
387
483
  throw new Error(`Patch validation failed — the patch was NOT added. Fix the problem and call addPatch again. ${(e as Error).message}`);
388
484
  }
@@ -456,10 +552,14 @@ async function dispatch(method: string, params: unknown): Promise<unknown> {
456
552
  }
457
553
  if (method === "tools/call") {
458
554
  let p = (params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
459
- let result = await callTool(p.name ?? "", p.arguments ?? {});
460
- return {
461
- content: [{ type: "text", text: JSON.stringify(result) }],
462
- };
555
+ try {
556
+ let result = await callTool(p.name ?? "", p.arguments ?? {});
557
+ return {
558
+ content: [{ type: "text", text: JSON.stringify(result) }],
559
+ };
560
+ } finally {
561
+ checkStopAfterStep(`tool ${p.name}`);
562
+ }
463
563
  }
464
564
  if (method === "ping") {
465
565
  return {};
@@ -519,7 +619,7 @@ async function startToolServer(): Promise<void> {
519
619
  });
520
620
  });
521
621
  await new Promise<void>((resolve, reject) => {
522
- server.once("error", reject);
622
+ server.once("error", e => reject(new Error(`AutoFixer tool server could not listen on port ${TOOL_SERVER_PORT} (is another autofixer already running on this machine?): ${e.stack ?? e}`)));
523
623
  server.listen(TOOL_SERVER_PORT, "127.0.0.1", () => resolve());
524
624
  });
525
625
  console.log(`AutoFixer tool server listening on http://127.0.0.1:${TOOL_SERVER_PORT}`);
@@ -527,6 +627,17 @@ async function startToolServer(): Promise<void> {
527
627
 
528
628
  // ==================== Investigation runs ====================
529
629
 
630
+ // Read fresh per run so edits to the overview apply without restarting the autofixer.
631
+ function getQuerysubOverview(): string {
632
+ let overviewPath = path.join(QUERYSUB_ROOT, "overview.md");
633
+ try {
634
+ return fs.readFileSync(overviewPath, "utf8");
635
+ } catch (e) {
636
+ console.error(`AutoFixer could not read ${overviewPath}:`, (e as Error).stack ?? e);
637
+ return "";
638
+ }
639
+ }
640
+
530
641
  function buildPrompt(ticket: Ticket): string {
531
642
  let priorComments: string[] = [];
532
643
  for (let comment of ticket.comments) {
@@ -545,7 +656,8 @@ function buildPrompt(ticket: Ticket): string {
545
656
  priorCommentsText = `(older comments omitted)\n` + priorCommentsText.slice(-MAX_PRIOR_COMMENTS_PROMPT_CHARS);
546
657
  }
547
658
 
548
- return `You are an automated bug investigator ("autofixer") working on a ticket created from a production error.
659
+ let overview = getQuerysubOverview();
660
+ return `${overview && `==== QUERYSUB OVERVIEW (how the framework works) ====\n${overview.trim()}\n\n` || ""}You are an automated bug investigator ("autofixer") working on a ticket created from a production error.
549
661
 
550
662
  The application repository is at ${process.cwd()}, and it is built on the querysub framework, whose repository is at ${QUERYSUB_ROOT}. Your job is to fix bugs in BOTH repositories — the bug may be in the application or in querysub itself, so read and patch whichever one the problem actually lives in. Use Read/Glob/Grep to read the code, and the mcp__autofixer__searchLogs / mcp__autofixer__listNodes tools to search the production logs.
551
663
 
@@ -585,6 +697,21 @@ const QUERYSUB_ROOT = path.resolve(__dirname, "../../../..");
585
697
  // Set for the duration of a claude run; called whenever a turn finishes (a stream-json "result" event), so the run loop can decide to forward new user comments or end the session.
586
698
  let claudeTurnEndedHandler: (() => void) | undefined = undefined;
587
699
 
700
+ // Set for the duration of a claude run so a stop request can kill it between steps.
701
+ let currentClaudeChild: ChildProcess | undefined = undefined;
702
+ let currentRunStopped = false;
703
+
704
+ // Called at step boundaries (a tool call finished, an assistant message arrived). Killing here means the in-flight step completed, but the AI never gets to loop again.
705
+ function checkStopAfterStep(context: string) {
706
+ if (currentRunStopped) return;
707
+ if (!currentTicketId || !stopRequestedTicketIds.has(currentTicketId)) return;
708
+ currentRunStopped = true;
709
+ console.log(green(`Stop requested for ticket ${currentTicketId}; ending the investigation after ${context}`));
710
+ if (currentClaudeChild) {
711
+ killChildTree(currentClaudeChild);
712
+ }
713
+ }
714
+
588
715
  // Built-in tools (Read/Glob/Grep) don't go through our MCP server, so we record them from the stream instead: tool_use blocks are stashed here until their tool_result arrives in a "user" event. Our own mcp__autofixer__ tools are excluded — callTool already records those.
589
716
  let pendingStreamToolCalls = new Map<string, { name: string; input: string; startTime: number }>();
590
717
 
@@ -645,6 +772,7 @@ function handleClaudeStreamLine(line: string): boolean {
645
772
  }
646
773
  }
647
774
  }
775
+ checkStopAfterStep(`an assistant message`);
648
776
  return true;
649
777
  }
650
778
  if (event.type === "result") {
@@ -683,10 +811,18 @@ function handleClaudeStreamLine(line: string): boolean {
683
811
  console.error(`Failed to record tool call in ticket ${ticketId}:`, (e as Error).stack ?? e);
684
812
  });
685
813
  }
814
+ checkStopAfterStep(`a tool result`);
686
815
  return true;
687
816
  }
688
817
  if (event.type === "system") {
689
- // Init/config noise.
818
+ if (event.subtype === "init" && typeof event.model === "string") {
819
+ currentRunResolvedModel = event.model;
820
+ // A run without --model reveals what the default resolves to, saving a probe.
821
+ if (!currentRunRequestedModel && !defaultClaudeModel) {
822
+ defaultClaudeModel = event.model;
823
+ }
824
+ console.log(`[claude] session model: ${event.model}`);
825
+ }
690
826
  return true;
691
827
  }
692
828
  return false;
@@ -697,6 +833,15 @@ function quoteArgForShell(arg: string): string {
697
833
  return `"${arg.replace(/"/g, "\\\"")}"`;
698
834
  }
699
835
 
836
+ function spawnClaudeProcess(args: string[]): ChildProcess {
837
+ if (process.platform === "win32") {
838
+ // 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.
839
+ let commandLine = ["claude", ...args].map(quoteArgForShell).join(" ");
840
+ return spawn(commandLine, { shell: true, stdio: ["pipe", "pipe", "pipe"] });
841
+ }
842
+ return spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
843
+ }
844
+
700
845
  function killChildTree(child: ChildProcess) {
701
846
  if (!child.pid) return;
702
847
  if (process.platform === "win32") {
@@ -706,7 +851,77 @@ function killChildTree(child: ChildProcess) {
706
851
  }
707
852
  }
708
853
 
709
- async function runClaude(prompt: string, takeNewUserComments: () => Promise<string[]>): Promise<{ timedOut: boolean; exitCode: number | undefined }> {
854
+ const DEFAULT_MODEL_PROBE_TIMEOUT = timeInMinute;
855
+
856
+ let defaultModelProbe: Promise<void> | undefined = undefined;
857
+
858
+ // Kicked off from status queries so the ticket page can show what "default" resolves to. The outcome (success or failure) is cached for the process lifetime — the default doesn't change while we run, and retrying a missing claude install on every 5s status poll would spawn endlessly.
859
+ export function ensureDefaultModelProbed(): void {
860
+ if (defaultClaudeModel || defaultModelProbe) return;
861
+ defaultModelProbe = (async () => {
862
+ try {
863
+ defaultClaudeModel = await probeDefaultClaudeModel();
864
+ console.log(green(`AutoFixer resolved the default claude model: ${defaultClaudeModel}`));
865
+ } catch (e) {
866
+ console.error(`AutoFixer failed to resolve the default claude model (will not retry):`, (e as Error).stack ?? e);
867
+ }
868
+ })();
869
+ }
870
+
871
+ // The stream-json init event names the session's model and arrives before the first API response, so killing the process the moment it appears makes this a near-free query for what --model defaults to.
872
+ async function probeDefaultClaudeModel(): Promise<string> {
873
+ let child = spawnClaudeProcess([
874
+ "-p",
875
+ "--output-format", "stream-json",
876
+ "--input-format", "stream-json",
877
+ "--verbose",
878
+ "--strict-mcp-config",
879
+ ]);
880
+ return await new Promise<string>((resolve, reject) => {
881
+ let settled = false;
882
+ function finish(err: Error | undefined, model?: string) {
883
+ if (settled) return;
884
+ settled = true;
885
+ clearTimeout(timeout);
886
+ killChildTree(child);
887
+ if (err) {
888
+ reject(err);
889
+ } else {
890
+ resolve(model!);
891
+ }
892
+ }
893
+ let timeout = setTimeout(() => finish(new Error(`Timed out waiting for the claude init event`)), DEFAULT_MODEL_PROBE_TIMEOUT);
894
+ let pending = "";
895
+ child.stdout!.on("data", (chunk: Buffer) => {
896
+ pending += chunk.toString("utf8");
897
+ let lines = pending.split("\n");
898
+ pending = lines.pop() ?? "";
899
+ for (let line of lines) {
900
+ let event: any;
901
+ try {
902
+ event = JSON.parse(line);
903
+ } catch {
904
+ continue;
905
+ }
906
+ if (event?.type === "system" && event.subtype === "init" && typeof event.model === "string") {
907
+ finish(undefined, event.model);
908
+ }
909
+ }
910
+ });
911
+ child.on("error", e => finish(e as Error));
912
+ child.on("exit", code => finish(new Error(`claude exited (code ${code}) before emitting its init event`)));
913
+ try {
914
+ child.stdin!.write(JSON.stringify({
915
+ type: "user",
916
+ message: { role: "user", content: [{ type: "text", text: "Reply with OK." }] },
917
+ }) + "\n");
918
+ } catch (e) {
919
+ finish(e as Error);
920
+ }
921
+ });
922
+ }
923
+
924
+ async function runClaude(prompt: string, model: string | undefined, takeNewUserComments: () => Promise<string[]>): Promise<{ timedOut: boolean; exitCode: number | undefined }> {
710
925
  let mcpConfigPath = path.join(os.tmpdir(), `autofixer-mcp-${process.pid}.json`);
711
926
  fs.writeFileSync(mcpConfigPath, JSON.stringify({
712
927
  mcpServers: {
@@ -727,16 +942,13 @@ async function runClaude(prompt: string, takeNewUserComments: () => Promise<stri
727
942
  "--strict-mcp-config",
728
943
  "--allowedTools", CLAUDE_ALLOWED_TOOLS.join(","),
729
944
  ];
730
-
731
- let child: ChildProcess;
732
- if (process.platform === "win32") {
733
- // 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.
734
- let commandLine = ["claude", ...args].map(quoteArgForShell).join(" ");
735
- child = spawn(commandLine, { shell: true, stdio: ["pipe", "pipe", "pipe"] });
736
- } else {
737
- child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
945
+ if (model) {
946
+ args.push("--model", model);
738
947
  }
739
948
 
949
+ let child = spawnClaudeProcess(args);
950
+ currentClaudeChild = child;
951
+
740
952
  function writeUserMessage(text: string) {
741
953
  if (child.exitCode !== null || child.killed) return;
742
954
  try {
@@ -753,6 +965,16 @@ async function runClaude(prompt: string, takeNewUserComments: () => Promise<stri
753
965
  // After each turn: forward any user comments added to the ticket during the turn as a new user message, otherwise end the session.
754
966
  claudeTurnEndedHandler = () => {
755
967
  void (async () => {
968
+ if (currentTicketId && stopRequestedTicketIds.has(currentTicketId)) {
969
+ currentRunStopped = true;
970
+ console.log(green(`Stop requested for ticket ${currentTicketId}; ending the claude session at turn end`));
971
+ try {
972
+ child.stdin!.end();
973
+ } catch {
974
+ // Process already exited.
975
+ }
976
+ return;
977
+ }
756
978
  let newComments: string[] = [];
757
979
  try {
758
980
  newComments = await takeNewUserComments();
@@ -812,6 +1034,7 @@ async function runClaude(prompt: string, takeNewUserComments: () => Promise<stri
812
1034
  child.on("exit", code => resolve(code ?? undefined));
813
1035
  });
814
1036
  claudeTurnEndedHandler = undefined;
1037
+ currentClaudeChild = undefined;
815
1038
  clearTimeout(timeout);
816
1039
  return { timedOut, exitCode };
817
1040
  }
@@ -820,17 +1043,21 @@ async function runInvestigation(ticket: Ticket): Promise<void> {
820
1043
  console.log(green(`AutoFixer starting investigation of ticket ${ticket.id}: ${ticket.title}`));
821
1044
  let service = await ticketService();
822
1045
 
1046
+ let model = ticketModelOverrides.get(ticket.id);
823
1047
  currentTicketId = ticket.id;
824
1048
  currentTicketTitle = ticket.title;
825
1049
  currentTicketStartTime = Date.now();
1050
+ currentRunRequestedModel = model;
1051
+ currentRunResolvedModel = undefined;
826
1052
  runTokens = emptyTokens();
827
1053
  pendingStreamToolCalls.clear();
828
1054
  stateChangedDuringRun = false;
829
1055
  patchAddedDuringRun = false;
1056
+ currentRunStopped = false;
830
1057
  try {
831
1058
  await addTicketComment(ticket.id, {
832
1059
  kind: "text",
833
- text: `Starting automated investigation (timeout ${formatTime(INVESTIGATION_TIMEOUT)}).`,
1060
+ text: `Starting automated investigation (model: ${model ?? defaultClaudeModel ?? "default"}, timeout ${formatTime(INVESTIGATION_TIMEOUT)}).`,
834
1061
  });
835
1062
 
836
1063
  // Any text comment that appears on the ticket during the run and wasn't written by us is a user comment — forwarded into the claude session between turns.
@@ -849,7 +1076,16 @@ async function runInvestigation(ticket: Ticket): Promise<void> {
849
1076
  return newComments.map(c => c.text);
850
1077
  }
851
1078
 
852
- let { timedOut, exitCode } = await runClaude(buildPrompt(ticket), takeNewUserComments);
1079
+ let { timedOut, exitCode } = await runClaude(buildPrompt(ticket), model, takeNewUserComments);
1080
+
1081
+ if (currentRunStopped) {
1082
+ console.log(green(`AutoFixer investigation of ticket ${ticket.id} was stopped by the user`));
1083
+ await addTicketComment(ticket.id, {
1084
+ kind: "text",
1085
+ text: `Automated investigation stopped by the user (the in-flight step was allowed to finish). The ticket state was left unchanged.`,
1086
+ });
1087
+ return;
1088
+ }
853
1089
 
854
1090
  if (timedOut) {
855
1091
  await addTicketComment(ticket.id, {
@@ -880,15 +1116,25 @@ async function runInvestigation(ticket: Ticket): Promise<void> {
880
1116
  }
881
1117
  } finally {
882
1118
  addTokens(totalTokens, runTokens);
1119
+ stopRequestedTicketIds.delete(ticket.id);
1120
+ ticketModelOverrides.delete(ticket.id);
1121
+ currentRunRequestedModel = undefined;
1122
+ currentRunResolvedModel = undefined;
883
1123
  currentTicketId = undefined;
884
1124
  currentTicketTitle = undefined;
885
1125
  }
886
1126
  }
887
1127
 
888
- export async function runAutoFixer(): Promise<void> {
889
- setAutoFixerHandlers({ onTicketsChanged, applyPatch });
1128
+ // Shared by the dedicated autofixer process and by nodes that only run manual investigations (triggered from the ticket page).
1129
+ const ensureAutoFixerBase = lazy(async () => {
1130
+ setAutoFixerHandlers({ onTicketsChanged, applyPatches });
890
1131
  SocketFunction.expose(AutoFixerControllerBase);
891
1132
  await startToolServer();
1133
+ });
1134
+
1135
+ export async function runAutoFixer(): Promise<void> {
1136
+ autoProcessingEnabled = true;
1137
+ await ensureAutoFixerBase();
892
1138
 
893
1139
  void runInfinitePollCallAtStart(REGISTER_POLL_INTERVAL, registerWithTicketService);
894
1140
  void runInfinitePollCallAtStart(ALL_TICKETS_POLL_INTERVAL, pollAllTickets);
@@ -4,7 +4,7 @@ import { assertIsManagementUser } from "../../managementPages";
4
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
5
  export type AutoFixerHandlers = {
6
6
  onTicketsChanged(ticketIds: string[]): void;
7
- applyPatch(ticketId: string, commentId: string): Promise<void>;
7
+ applyPatches(ticketId: string, commentIds: string[]): Promise<void>;
8
8
  };
9
9
 
10
10
  let handlers: AutoFixerHandlers | undefined = undefined;
@@ -17,11 +17,11 @@ class AutoFixerController {
17
17
  handlers?.onTicketsChanged(ticketIds);
18
18
  }
19
19
 
20
- public async applyPatch(ticketId: string, commentId: string) {
20
+ public async applyPatches(ticketId: string, commentIds: string[]) {
21
21
  if (!handlers) {
22
22
  throw new Error(`AutoFixer is not running on this node, so patches cannot be applied here`);
23
23
  }
24
- await handlers.applyPatch(ticketId, commentId);
24
+ await handlers.applyPatches(ticketId, commentIds);
25
25
  }
26
26
  }
27
27
 
@@ -30,7 +30,7 @@ export const AutoFixerControllerBase = SocketFunction.register(
30
30
  new AutoFixerController(),
31
31
  () => ({
32
32
  onTicketsChanged: {},
33
- applyPatch: {},
33
+ applyPatches: {},
34
34
  }),
35
35
  () => ({
36
36
  hooks: [assertIsManagementUser],
@@ -36,6 +36,23 @@ export type TicketComment = {
36
36
  toolDurationMs?: number;
37
37
  };
38
38
 
39
+ // Model aliases accepted by the claude CLI's --model flag. Aliases (not full model IDs) so they keep resolving to the latest model of each tier as the CLI updates.
40
+ export const AUTOFIXER_MODEL_OPTIONS = ["opus", "sonnet", "haiku"];
41
+
42
+ // Status of the autofixer machinery on a single node (the node that answered the status query).
43
+ export type AutoFixerRunStatus = {
44
+ runningTicketId?: string;
45
+ runningTicketTitle?: string;
46
+ runStartTime?: number;
47
+ // The actual model of the current run (from claude's init event), falling back to the requested model until the run reports it.
48
+ runningModel?: string;
49
+ // What claude resolves when no --model is passed. Undefined until the probe (or a default-model run) has resolved it.
50
+ defaultModel?: string;
51
+ // A stop was requested for the running ticket — the current step finishes and then the run ends.
52
+ stopRequested?: boolean;
53
+ queuedTicketIds: string[];
54
+ };
55
+
39
56
  export type Ticket = {
40
57
  id: string;
41
58
  title: string;