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.
- package/package.json +1 -1
- package/src/0-path-value-core/PathRouter.ts +2 -2
- package/src/0-path-value-core/archiveLocks/ArchiveLocks2.ts +18 -16
- package/src/2-proxy/PathValueProxyWatcher.ts +1 -1
- package/src/5-diagnostics/Modal.tsx +10 -2
- package/src/diagnostics/FunctionCallInfoState.ts +29 -89
- package/src/diagnostics/logs/errorTickets/TicketPage.tsx +151 -32
- package/src/diagnostics/logs/errorTickets/autoFixer.ts +303 -57
- package/src/diagnostics/logs/errorTickets/autoFixerController.ts +4 -4
- package/src/diagnostics/logs/errorTickets/ticketTypes.ts +17 -0
- package/src/diagnostics/logs/errorTickets/tickets.ts +49 -24
- package/src/diagnostics/statsDefinitions.tsx +8 -19
- package/src/library-components/URLParam.ts +11 -3
|
@@ -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
|
-
|
|
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
|
-
//
|
|
165
|
-
function
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
269
|
+
overlay.set(current?.fullPath ?? candidates[0], patchFile.newText);
|
|
188
270
|
continue;
|
|
189
271
|
}
|
|
190
|
-
if (!
|
|
272
|
+
if (!current) {
|
|
191
273
|
throw new Error(`Patch targets ${patchFile.file}, which does not exist. Tried: ${candidates.join(", ")}`);
|
|
192
274
|
}
|
|
193
|
-
let contents =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
fs.
|
|
222
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
889
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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;
|