@gethmy/agent 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/cli.js +6376 -205
- package/dist/index.js +6206 -341
- package/package.json +2 -2
- package/dist/board-helpers.d.ts +0 -31
- package/dist/board-helpers.js +0 -150
- package/dist/budget.d.ts +0 -47
- package/dist/budget.js +0 -161
- package/dist/cli.d.ts +0 -16
- package/dist/completion.d.ts +0 -32
- package/dist/completion.js +0 -304
- package/dist/config-validation.d.ts +0 -23
- package/dist/config-validation.js +0 -77
- package/dist/config.d.ts +0 -23
- package/dist/config.js +0 -103
- package/dist/episode-writer.d.ts +0 -84
- package/dist/episode-writer.js +0 -232
- package/dist/git-pr.d.ts +0 -38
- package/dist/git-pr.js +0 -399
- package/dist/http-server.d.ts +0 -79
- package/dist/http-server.js +0 -114
- package/dist/index.d.ts +0 -5
- package/dist/log.d.ts +0 -34
- package/dist/log.js +0 -100
- package/dist/merge-monitor.d.ts +0 -23
- package/dist/merge-monitor.js +0 -169
- package/dist/pm.d.ts +0 -14
- package/dist/pm.js +0 -63
- package/dist/pool.d.ts +0 -70
- package/dist/pool.js +0 -258
- package/dist/process-group.d.ts +0 -26
- package/dist/process-group.js +0 -72
- package/dist/progress-tracker.d.ts +0 -79
- package/dist/progress-tracker.js +0 -442
- package/dist/prompt.d.ts +0 -18
- package/dist/prompt.js +0 -117
- package/dist/queue.d.ts +0 -39
- package/dist/queue.js +0 -100
- package/dist/reconcile.d.ts +0 -35
- package/dist/reconcile.js +0 -174
- package/dist/recovery.d.ts +0 -30
- package/dist/recovery.js +0 -141
- package/dist/review-completion.d.ts +0 -40
- package/dist/review-completion.js +0 -474
- package/dist/review-knowledge.d.ts +0 -14
- package/dist/review-knowledge.js +0 -89
- package/dist/review-prompt.d.ts +0 -12
- package/dist/review-prompt.js +0 -103
- package/dist/review-worker.d.ts +0 -56
- package/dist/review-worker.js +0 -638
- package/dist/review-worktree.d.ts +0 -12
- package/dist/review-worktree.js +0 -95
- package/dist/run-log.d.ts +0 -6
- package/dist/run-log.js +0 -19
- package/dist/startup-banner.d.ts +0 -29
- package/dist/startup-banner.js +0 -143
- package/dist/state-store.d.ts +0 -88
- package/dist/state-store.js +0 -239
- package/dist/stream-parser-selftest.d.ts +0 -9
- package/dist/stream-parser-selftest.js +0 -97
- package/dist/stream-parser.d.ts +0 -43
- package/dist/stream-parser.js +0 -174
- package/dist/transitions.d.ts +0 -57
- package/dist/transitions.js +0 -131
- package/dist/types.d.ts +0 -140
- package/dist/types.js +0 -79
- package/dist/verification.d.ts +0 -39
- package/dist/verification.js +0 -317
- package/dist/watcher.d.ts +0 -53
- package/dist/watcher.js +0 -153
- package/dist/worker.d.ts +0 -53
- package/dist/worker.js +0 -464
- package/dist/worktree-gc.d.ts +0 -67
- package/dist/worktree-gc.js +0 -245
- package/dist/worktree.d.ts +0 -18
- package/dist/worktree.js +0 -177
package/dist/review-worker.js
DELETED
|
@@ -1,638 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
|
|
4
|
-
import { buildTokenPayload } from "./completion.js";
|
|
5
|
-
import { log } from "./log.js";
|
|
6
|
-
import { signalGroup, spawnInGroup, terminateGroup } from "./process-group.js";
|
|
7
|
-
import { ProgressTracker } from "./progress-tracker.js";
|
|
8
|
-
import { parseReviewOutput, runReviewCompletion } from "./review-completion.js";
|
|
9
|
-
import { buildReviewSystemPrompt, buildReviewUserPrompt, } from "./review-prompt.js";
|
|
10
|
-
import { checkoutExistingBranch, extractBranchFromDescription, } from "./review-worktree.js";
|
|
11
|
-
import { openRunLog } from "./run-log.js";
|
|
12
|
-
import { newRunId } from "./state-store.js";
|
|
13
|
-
import { StreamParser } from "./stream-parser.js";
|
|
14
|
-
import { runTransition, TransitionError } from "./transitions.js";
|
|
15
|
-
import { AGENT_NAME, agentIdentifier, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR, } from "./types.js";
|
|
16
|
-
import { DevServerReadinessError, probeDevServer, waitForDevServer, } from "./verification.js";
|
|
17
|
-
import { cleanupWorktree } from "./worktree.js";
|
|
18
|
-
const TAG = "review-worker";
|
|
19
|
-
const CANCEL_SIGINT_TIMEOUT = 30_000;
|
|
20
|
-
const CANCEL_SIGTERM_TIMEOUT = 10_000;
|
|
21
|
-
export class ReviewWorker {
|
|
22
|
-
config;
|
|
23
|
-
client;
|
|
24
|
-
onDone;
|
|
25
|
-
stateStore;
|
|
26
|
-
workspaceId;
|
|
27
|
-
id;
|
|
28
|
-
state = "idle";
|
|
29
|
-
cardId = null;
|
|
30
|
-
branchName = null;
|
|
31
|
-
worktreePath = null;
|
|
32
|
-
startedAt = null;
|
|
33
|
-
process = null;
|
|
34
|
-
devServerProcess = null;
|
|
35
|
-
timeoutTimer = null;
|
|
36
|
-
heartbeatTimer = null;
|
|
37
|
-
progressTracker = null;
|
|
38
|
-
lastSessionStats = null;
|
|
39
|
-
aborted = false;
|
|
40
|
-
runId = null;
|
|
41
|
-
lastRunLogPath = null;
|
|
42
|
-
sessionId = null;
|
|
43
|
-
constructor(id, config, client, _userEmail, onDone, stateStore, workspaceId, _projectId) {
|
|
44
|
-
this.config = config;
|
|
45
|
-
this.client = client;
|
|
46
|
-
this.onDone = onDone;
|
|
47
|
-
this.stateStore = stateStore;
|
|
48
|
-
this.workspaceId = workspaceId;
|
|
49
|
-
this.id = id;
|
|
50
|
-
}
|
|
51
|
-
startHeartbeat() {
|
|
52
|
-
this.stopHeartbeat();
|
|
53
|
-
const interval = this.config.timing.heartbeatMs;
|
|
54
|
-
this.heartbeatTimer = setInterval(() => {
|
|
55
|
-
if (this.runId) {
|
|
56
|
-
this.stateStore.heartbeat(this.runId).catch(() => {
|
|
57
|
-
/* retry next tick */
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}, interval);
|
|
61
|
-
// Don't block event loop shutdown for a pending heartbeat.
|
|
62
|
-
this.heartbeatTimer.unref();
|
|
63
|
-
}
|
|
64
|
-
stopHeartbeat() {
|
|
65
|
-
if (this.heartbeatTimer) {
|
|
66
|
-
clearInterval(this.heartbeatTimer);
|
|
67
|
-
this.heartbeatTimer = null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
async recordPhase(phase) {
|
|
71
|
-
if (!this.runId)
|
|
72
|
-
return;
|
|
73
|
-
try {
|
|
74
|
-
await this.stateStore.updateRun(this.runId, {
|
|
75
|
-
phase,
|
|
76
|
-
lastHeartbeatAt: Date.now(),
|
|
77
|
-
worktreePath: this.worktreePath,
|
|
78
|
-
branchName: this.branchName,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
log.warn(this.tag, `state store updateRun failed: ${err instanceof Error ? err.message : err}`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
get tag() {
|
|
86
|
-
return `${TAG}:${this.id}`;
|
|
87
|
-
}
|
|
88
|
-
get isIdle() {
|
|
89
|
-
return this.state === "idle";
|
|
90
|
-
}
|
|
91
|
-
get reviewPort() {
|
|
92
|
-
return this.config.review.devServerPort + (this.id - this.config.poolSize);
|
|
93
|
-
}
|
|
94
|
-
get isActive() {
|
|
95
|
-
return (this.state === "preparing" ||
|
|
96
|
-
this.state === "running" ||
|
|
97
|
-
this.state === "verifying" ||
|
|
98
|
-
this.state === "completing");
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Start reviewing a card. Runs the full lifecycle:
|
|
102
|
-
* PREPARING → REVIEWING → COMPLETING → IDLE
|
|
103
|
-
*/
|
|
104
|
-
async run(card, column, labels, subtasks) {
|
|
105
|
-
this.aborted = false;
|
|
106
|
-
this.cardId = card.id;
|
|
107
|
-
this.startedAt = Date.now();
|
|
108
|
-
this.runId = newRunId();
|
|
109
|
-
try {
|
|
110
|
-
// --- PREPARING ---
|
|
111
|
-
this.state = "preparing";
|
|
112
|
-
log.info(this.tag, `Preparing review for #${card.short_id} "${card.title}"`);
|
|
113
|
-
this.startHeartbeat();
|
|
114
|
-
await this.stateStore.insertRun({
|
|
115
|
-
runId: this.runId,
|
|
116
|
-
cardId: card.id,
|
|
117
|
-
cardShortId: card.short_id,
|
|
118
|
-
pipeline: "review",
|
|
119
|
-
workerId: this.id,
|
|
120
|
-
sessionId: null,
|
|
121
|
-
worktreePath: null,
|
|
122
|
-
branchName: null,
|
|
123
|
-
daemonPid: process.pid,
|
|
124
|
-
phase: "preparing",
|
|
125
|
-
startedAt: this.startedAt,
|
|
126
|
-
lastHeartbeatAt: this.startedAt,
|
|
127
|
-
endedAt: null,
|
|
128
|
-
status: "active",
|
|
129
|
-
costCents: 0,
|
|
130
|
-
});
|
|
131
|
-
// Extract branch name from card description
|
|
132
|
-
this.branchName = extractBranchFromDescription(card.description);
|
|
133
|
-
const localMode = !this.branchName;
|
|
134
|
-
let localDiff = null;
|
|
135
|
-
if (localMode) {
|
|
136
|
-
// LOCAL FALLBACK: no branch → review local working tree
|
|
137
|
-
log.info(this.tag, `No branch found for #${card.short_id}, attempting local review`);
|
|
138
|
-
// Use repo root as working directory
|
|
139
|
-
this.worktreePath = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
140
|
-
encoding: "utf-8",
|
|
141
|
-
timeout: 5_000,
|
|
142
|
-
}).trim();
|
|
143
|
-
// Try to find reviewable changes
|
|
144
|
-
const resolved = this.resolveLocalChanges(this.worktreePath, card.short_id);
|
|
145
|
-
if (!resolved) {
|
|
146
|
-
// No changes found — mark for human review and leave in current column
|
|
147
|
-
log.info(this.tag, `No local changes found for #${card.short_id} — marking for human review (staying in Review)`);
|
|
148
|
-
await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
log.info(this.tag, `Found local changes via ${resolved.source} for #${card.short_id}`);
|
|
152
|
-
localDiff = resolved.diff;
|
|
153
|
-
}
|
|
154
|
-
if (this.branchName) {
|
|
155
|
-
log.info(this.tag, `Review branch: ${this.branchName}`);
|
|
156
|
-
}
|
|
157
|
-
// Start agent session and make it visible on the board
|
|
158
|
-
const { session: reviewSession } = await this.client.startAgentSession(card.id, {
|
|
159
|
-
agentIdentifier: agentIdentifier(this.id),
|
|
160
|
-
agentName: `${AGENT_NAME} (Review)`,
|
|
161
|
-
status: "working",
|
|
162
|
-
currentTask: localMode
|
|
163
|
-
? "Reviewing local changes"
|
|
164
|
-
: "Setting up review worktree",
|
|
165
|
-
progressPercent: 5,
|
|
166
|
-
});
|
|
167
|
-
this.sessionId =
|
|
168
|
-
reviewSession &&
|
|
169
|
-
typeof reviewSession === "object" &&
|
|
170
|
-
"id" in reviewSession
|
|
171
|
-
? (reviewSession.id ?? null)
|
|
172
|
-
: null;
|
|
173
|
-
// Fire label addition concurrently with sync worktree checkout
|
|
174
|
-
const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
|
|
175
|
-
if (!localMode) {
|
|
176
|
-
// Checkout existing branch into worktree (sync)
|
|
177
|
-
this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
|
|
178
|
-
}
|
|
179
|
-
await labelPromise;
|
|
180
|
-
if (this.aborted)
|
|
181
|
-
return;
|
|
182
|
-
// --- REVIEWING ---
|
|
183
|
-
this.state = "running";
|
|
184
|
-
await this.recordPhase("running");
|
|
185
|
-
if (!this.worktreePath) {
|
|
186
|
-
throw new Error("worktreePath not set before review phase");
|
|
187
|
-
}
|
|
188
|
-
// Start dev server (only in branch mode — local mode uses existing server)
|
|
189
|
-
const port = this.reviewPort;
|
|
190
|
-
const cwd = this.worktreePath;
|
|
191
|
-
if (!localMode) {
|
|
192
|
-
log.info(this.tag, `Starting dev server on port ${port}...`);
|
|
193
|
-
this.devServerProcess = spawnInGroup("bun", ["run", "dev", "--", "--port", String(port)], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
194
|
-
// Surface the dev-server warmup as `waiting` so the AgentTopBar
|
|
195
|
-
// doesn't sit on a stale `working` task while the server takes
|
|
196
|
-
// 10–20 seconds to boot.
|
|
197
|
-
await this.client.updateAgentProgress(card.id, {
|
|
198
|
-
agentIdentifier: agentIdentifier(this.id),
|
|
199
|
-
agentName: `${AGENT_NAME} (Review)`,
|
|
200
|
-
status: "waiting",
|
|
201
|
-
currentTask: `Starting dev server on port ${port}…`,
|
|
202
|
-
progressPercent: 10,
|
|
203
|
-
});
|
|
204
|
-
// Invariant I2: review only proceeds with a proven-live dev server.
|
|
205
|
-
// waitForDevServer rejects on timeout / exit; probeDevServer verifies
|
|
206
|
-
// the port actually answers HTTP.
|
|
207
|
-
await waitForDevServer(this.devServerProcess, 30_000);
|
|
208
|
-
await probeDevServer(port);
|
|
209
|
-
log.info(this.tag, `Dev server ready on port ${port}`);
|
|
210
|
-
// Restore active status now that the server is live.
|
|
211
|
-
await this.client.updateAgentProgress(card.id, {
|
|
212
|
-
agentIdentifier: agentIdentifier(this.id),
|
|
213
|
-
agentName: `${AGENT_NAME} (Review)`,
|
|
214
|
-
status: "working",
|
|
215
|
-
currentTask: "Reviewing changes",
|
|
216
|
-
progressPercent: 15,
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
if (this.aborted)
|
|
220
|
-
return;
|
|
221
|
-
// Get diff
|
|
222
|
-
let diff = "";
|
|
223
|
-
try {
|
|
224
|
-
if (localMode) {
|
|
225
|
-
// Use pre-computed diff from resolveLocalChanges
|
|
226
|
-
diff = localDiff ?? "";
|
|
227
|
-
}
|
|
228
|
-
else {
|
|
229
|
-
diff = execFileSync("git", ["diff", `origin/${this.config.worktree.baseBranch}..HEAD`], { cwd, encoding: "utf-8", timeout: 30_000 });
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
diff = "(unable to retrieve diff)";
|
|
234
|
-
}
|
|
235
|
-
const previewUrl = `http://localhost:${port}`;
|
|
236
|
-
const enriched = {
|
|
237
|
-
card,
|
|
238
|
-
column,
|
|
239
|
-
labels,
|
|
240
|
-
subtasks,
|
|
241
|
-
mode: "review",
|
|
242
|
-
};
|
|
243
|
-
const systemPrompt = buildReviewSystemPrompt();
|
|
244
|
-
const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diff, this.config.worktree.baseBranch);
|
|
245
|
-
// AGP P2: persist a session-linked snapshot of the review system
|
|
246
|
-
// prompt so outcome feedback can credit / penalise the framing.
|
|
247
|
-
// Best-effort — never block review on logging.
|
|
248
|
-
try {
|
|
249
|
-
const sessionResp = await this.client.getAgentSession(card.id);
|
|
250
|
-
const reviewSession = sessionResp.session;
|
|
251
|
-
const reviewSessionId = reviewSession?.id ?? null;
|
|
252
|
-
const contentHash = createHash("sha256")
|
|
253
|
-
.update(systemPrompt)
|
|
254
|
-
.digest("hex");
|
|
255
|
-
await this.client.recordPromptHistory({
|
|
256
|
-
cardId: card.id,
|
|
257
|
-
generatedPrompt: systemPrompt,
|
|
258
|
-
variant: "execute",
|
|
259
|
-
contextIncluded: { source: "review-knowledge", mode: "review" },
|
|
260
|
-
sessionId: reviewSessionId,
|
|
261
|
-
contentHash,
|
|
262
|
-
templateVersion: 1,
|
|
263
|
-
confidence: 0.5,
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
catch (err) {
|
|
267
|
-
log.warn(this.tag, `prompt_history persistence skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
268
|
-
}
|
|
269
|
-
await this.client.updateAgentProgress(card.id, {
|
|
270
|
-
agentIdentifier: agentIdentifier(this.id),
|
|
271
|
-
agentName: `${AGENT_NAME} (Review)`,
|
|
272
|
-
status: "working",
|
|
273
|
-
currentTask: "Running Claude review",
|
|
274
|
-
progressPercent: 20,
|
|
275
|
-
});
|
|
276
|
-
// Start timeout watchdog
|
|
277
|
-
this.timeoutTimer = setTimeout(() => {
|
|
278
|
-
log.warn(this.tag, `Review timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
|
|
279
|
-
this.cancel();
|
|
280
|
-
}, this.config.review.maxTimeout);
|
|
281
|
-
// Set up progress tracking
|
|
282
|
-
this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
|
|
283
|
-
// Spawn Claude CLI for review with streaming
|
|
284
|
-
const stdout = await this.spawnClaude(userPrompt, systemPrompt, this.progressTracker, card.short_id);
|
|
285
|
-
// Capture session stats before stopping tracker
|
|
286
|
-
this.lastSessionStats = this.progressTracker?.stats ?? null;
|
|
287
|
-
this.progressTracker?.stop();
|
|
288
|
-
this.progressTracker = null;
|
|
289
|
-
const sessionStats = this.lastSessionStats;
|
|
290
|
-
if (this.aborted)
|
|
291
|
-
return;
|
|
292
|
-
// --- COMPLETING ---
|
|
293
|
-
this.state = "completing";
|
|
294
|
-
await this.recordPhase("completing");
|
|
295
|
-
log.info(this.tag, `Claude review finished for #${card.short_id}`);
|
|
296
|
-
// Kill dev server (only if we started one)
|
|
297
|
-
if (!localMode) {
|
|
298
|
-
this.killDevServer();
|
|
299
|
-
}
|
|
300
|
-
// Parse findings
|
|
301
|
-
const result = parseReviewOutput(stdout);
|
|
302
|
-
log.info(this.tag, `Review verdict: ${result.verdict} (${result.findings.length} finding(s))`);
|
|
303
|
-
await this.client.updateAgentProgress(card.id, {
|
|
304
|
-
agentIdentifier: agentIdentifier(this.id),
|
|
305
|
-
agentName: `${AGENT_NAME} (Review)`,
|
|
306
|
-
status: "working",
|
|
307
|
-
currentTask: `Processing ${result.verdict} verdict`,
|
|
308
|
-
progressPercent: 80,
|
|
309
|
-
});
|
|
310
|
-
// Run review completion pipeline
|
|
311
|
-
await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId, this.stateStore);
|
|
312
|
-
}
|
|
313
|
-
catch (err) {
|
|
314
|
-
this.state = "error";
|
|
315
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
316
|
-
log.error(this.tag, `Error reviewing #${card.short_id}: ${msg}`);
|
|
317
|
-
// End session as paused on error. Retried via runTransition so a
|
|
318
|
-
// transient API failure no longer orphans the session.
|
|
319
|
-
try {
|
|
320
|
-
const stats = this.lastSessionStats ?? this.progressTracker?.stats;
|
|
321
|
-
await runTransition(this.client, card, {
|
|
322
|
-
endSession: {
|
|
323
|
-
status: "paused",
|
|
324
|
-
...buildTokenPayload(stats),
|
|
325
|
-
},
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
catch (tErr) {
|
|
329
|
-
log.error(this.tag, `endAgentSession unrecoverable on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
|
|
330
|
-
}
|
|
331
|
-
// Dev server readiness failures are infrastructure errors, not
|
|
332
|
-
// implementation errors — the code under review may be perfectly
|
|
333
|
-
// fine. Invariant I2: never approve without a live server, but
|
|
334
|
-
// also never bounce the card back to "To Do" for rework. Keep
|
|
335
|
-
// it in Review with a "Need Review" label so a human steps in.
|
|
336
|
-
if (err instanceof DevServerReadinessError) {
|
|
337
|
-
try {
|
|
338
|
-
await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
|
|
339
|
-
log.info(this.tag, `#${card.short_id} kept in Review — dev server unavailable, human review needed`);
|
|
340
|
-
}
|
|
341
|
-
catch {
|
|
342
|
-
log.warn(this.tag, "Failed to add Need Review label after dev-server failure");
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
else {
|
|
346
|
-
// Move card out of Review to break the re-enqueue loop for other errors
|
|
347
|
-
try {
|
|
348
|
-
await moveCardToColumn(this.client, card, this.config.review.failColumn);
|
|
349
|
-
log.info(this.tag, `Moved #${card.short_id} to "${this.config.review.failColumn}" after error`);
|
|
350
|
-
}
|
|
351
|
-
catch {
|
|
352
|
-
log.warn(this.tag, "Failed to move card to fail column after error");
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
if (this.runId) {
|
|
356
|
-
try {
|
|
357
|
-
await this.stateStore.endRun(this.runId, this.state === "error" ? "paused" : "completed");
|
|
358
|
-
}
|
|
359
|
-
catch {
|
|
360
|
-
// best-effort
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
finally {
|
|
365
|
-
if (this.runId) {
|
|
366
|
-
try {
|
|
367
|
-
const run = this.stateStore.getRun(this.runId);
|
|
368
|
-
if (run && run.endedAt === null) {
|
|
369
|
-
// Cancelled runs are paused, not completed — they never
|
|
370
|
-
// produced a verdict so treating them as success would be
|
|
371
|
-
// a lie to the budget/attempt bookkeeping.
|
|
372
|
-
const status = this.state === "error" || this.aborted ? "paused" : "completed";
|
|
373
|
-
await this.stateStore.endRun(this.runId, status);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
catch {
|
|
377
|
-
// best-effort
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
this.cleanup();
|
|
381
|
-
this.state = "idle";
|
|
382
|
-
this.onDone(this);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* Pause the current review by suspending the Claude process (SIGSTOP).
|
|
387
|
-
*/
|
|
388
|
-
async pause() {
|
|
389
|
-
if (!this.isActive || !this.process || this.process.killed)
|
|
390
|
-
return;
|
|
391
|
-
log.info(this.tag, `Pausing review on ${this.cardId}`);
|
|
392
|
-
signalGroup(this.process, "SIGSTOP");
|
|
393
|
-
if (this.timeoutTimer) {
|
|
394
|
-
clearTimeout(this.timeoutTimer);
|
|
395
|
-
this.timeoutTimer = null;
|
|
396
|
-
}
|
|
397
|
-
// Update agent session so the UI reflects the paused state
|
|
398
|
-
if (this.cardId) {
|
|
399
|
-
try {
|
|
400
|
-
await this.client.updateAgentProgress(this.cardId, {
|
|
401
|
-
agentIdentifier: agentIdentifier(this.id),
|
|
402
|
-
agentName: AGENT_NAME,
|
|
403
|
-
status: "paused",
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
catch {
|
|
407
|
-
log.warn(this.tag, "Failed to update agent session to paused");
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
/**
|
|
412
|
-
* Resume the Claude process after a pause (SIGCONT).
|
|
413
|
-
*/
|
|
414
|
-
async resume() {
|
|
415
|
-
if (!this.isActive || !this.process || this.process.killed)
|
|
416
|
-
return;
|
|
417
|
-
log.info(this.tag, `Resuming review on ${this.cardId}`);
|
|
418
|
-
signalGroup(this.process, "SIGCONT");
|
|
419
|
-
this.timeoutTimer = setTimeout(() => {
|
|
420
|
-
log.warn(this.tag, `Timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
|
|
421
|
-
this.cancel();
|
|
422
|
-
}, this.config.review.maxTimeout);
|
|
423
|
-
// Update agent session so the UI reflects the resumed state
|
|
424
|
-
if (this.cardId) {
|
|
425
|
-
try {
|
|
426
|
-
await this.client.updateAgentProgress(this.cardId, {
|
|
427
|
-
agentIdentifier: agentIdentifier(this.id),
|
|
428
|
-
agentName: AGENT_NAME,
|
|
429
|
-
status: "working",
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
catch {
|
|
433
|
-
log.warn(this.tag, "Failed to update agent session to working");
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
/**
|
|
438
|
-
* Cancel the current review. Sends escalating signals to both processes.
|
|
439
|
-
*/
|
|
440
|
-
async cancel() {
|
|
441
|
-
if (!this.isActive)
|
|
442
|
-
return;
|
|
443
|
-
this.aborted = true;
|
|
444
|
-
this.state = "cancelling";
|
|
445
|
-
log.info(this.tag, `Cancelling review on ${this.cardId}`);
|
|
446
|
-
// Capture tokens/cost snapshot before stopping the tracker so a
|
|
447
|
-
// cancelled run still persists its usage to the session.
|
|
448
|
-
const snapshotStats = this.lastSessionStats ?? this.progressTracker?.stats;
|
|
449
|
-
// Stop progress tracking
|
|
450
|
-
if (this.progressTracker) {
|
|
451
|
-
this.progressTracker?.stop();
|
|
452
|
-
this.progressTracker = null;
|
|
453
|
-
}
|
|
454
|
-
// Kill dev server first
|
|
455
|
-
this.killDevServer();
|
|
456
|
-
// Then kill Claude process group with escalating signals
|
|
457
|
-
if (this.process && !this.process.killed) {
|
|
458
|
-
await terminateGroup(this.process, {
|
|
459
|
-
sigintTimeoutMs: CANCEL_SIGINT_TIMEOUT,
|
|
460
|
-
sigtermTimeoutMs: CANCEL_SIGTERM_TIMEOUT,
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
// End agent session as paused
|
|
464
|
-
if (this.cardId) {
|
|
465
|
-
try {
|
|
466
|
-
await this.client.endAgentSession(this.cardId, {
|
|
467
|
-
status: "paused",
|
|
468
|
-
...buildTokenPayload(snapshotStats),
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
catch {
|
|
472
|
-
// best-effort
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
spawnClaude(prompt, systemPrompt, tracker, shortId) {
|
|
477
|
-
return new Promise((resolve, reject) => {
|
|
478
|
-
const args = [
|
|
479
|
-
"--output-format",
|
|
480
|
-
"stream-json",
|
|
481
|
-
"--verbose",
|
|
482
|
-
"--model",
|
|
483
|
-
this.config.claude.reviewModel,
|
|
484
|
-
"--max-turns",
|
|
485
|
-
String(this.config.claude.maxTurns),
|
|
486
|
-
"--allowedTools",
|
|
487
|
-
"Bash(readonly),Read,Glob,Grep,Agent,mcp__harmony__*",
|
|
488
|
-
...(systemPrompt ? ["--append-system-prompt", systemPrompt] : []),
|
|
489
|
-
...this.config.claude.additionalArgs,
|
|
490
|
-
"--",
|
|
491
|
-
prompt,
|
|
492
|
-
];
|
|
493
|
-
log.info(this.tag, `Spawning review: claude ${args.slice(0, 5).join(" ")} ...`);
|
|
494
|
-
const runLog = openRunLog(this.tag, this.runId, shortId);
|
|
495
|
-
this.lastRunLogPath = runLog?.path ?? null;
|
|
496
|
-
if (runLog) {
|
|
497
|
-
log.info(this.tag, `Run log: ${runLog.path}`);
|
|
498
|
-
runLog.stream.write(`# run=${this.runId} card=#${shortId} pipeline=review started=${new Date().toISOString()}\n` +
|
|
499
|
-
`# args: ${args.slice(0, -2).join(" ")} -- <prompt:${prompt.length} chars>\n\n`);
|
|
500
|
-
}
|
|
501
|
-
this.process = spawnInGroup("claude", args, {
|
|
502
|
-
cwd: this.worktreePath,
|
|
503
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
504
|
-
});
|
|
505
|
-
// Stream parser for progress tracking + text reconstruction for verdict
|
|
506
|
-
const parser = new StreamParser();
|
|
507
|
-
tracker.attach(parser);
|
|
508
|
-
const textChunks = [];
|
|
509
|
-
parser.on("text", (content) => {
|
|
510
|
-
textChunks.push(content);
|
|
511
|
-
});
|
|
512
|
-
parser.on("parse_error", (msg) => {
|
|
513
|
-
log.debug(this.tag, `Stream parse error (non-fatal): ${msg}`);
|
|
514
|
-
runLog?.stream.write(`\n[parse_error] ${msg}\n`);
|
|
515
|
-
});
|
|
516
|
-
// Attach parser to stdout (single consumer)
|
|
517
|
-
if (this.process.stdout) {
|
|
518
|
-
parser.attach(this.process.stdout);
|
|
519
|
-
if (runLog) {
|
|
520
|
-
this.process.stdout.on("data", (chunk) => {
|
|
521
|
-
runLog.stream.write(chunk);
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
let stderr = "";
|
|
526
|
-
this.process.stderr?.on("data", (data) => {
|
|
527
|
-
stderr += data.toString();
|
|
528
|
-
runLog?.stream.write(`[stderr] ${data.toString()}`);
|
|
529
|
-
});
|
|
530
|
-
this.process.on("error", (err) => {
|
|
531
|
-
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
532
|
-
});
|
|
533
|
-
this.process.on("close", (code) => {
|
|
534
|
-
this.process = null;
|
|
535
|
-
// Reconstruct text from stream parser text events
|
|
536
|
-
const stdout = textChunks.join("");
|
|
537
|
-
const stats = tracker.stats;
|
|
538
|
-
if (runLog) {
|
|
539
|
-
runLog.stream.write(`\n# exit code=${code} aborted=${this.aborted} ` +
|
|
540
|
-
`toolCalls=${stats?.toolCalls ?? 0} filesEdited=${stats?.filesEdited ?? 0} ` +
|
|
541
|
-
`cost=$${stats?.cost?.totalCostUsd.toFixed(4) ?? "0"} ` +
|
|
542
|
-
`textChars=${stdout.length} ended=${new Date().toISOString()}\n`);
|
|
543
|
-
runLog.stream.end();
|
|
544
|
-
}
|
|
545
|
-
if (this.aborted) {
|
|
546
|
-
resolve(stdout);
|
|
547
|
-
}
|
|
548
|
-
else if (code === 0) {
|
|
549
|
-
resolve(stdout);
|
|
550
|
-
}
|
|
551
|
-
else {
|
|
552
|
-
reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
|
|
553
|
-
}
|
|
554
|
-
});
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
killDevServer() {
|
|
558
|
-
if (this.devServerProcess && !this.devServerProcess.killed) {
|
|
559
|
-
signalGroup(this.devServerProcess, "SIGTERM");
|
|
560
|
-
this.devServerProcess = null;
|
|
561
|
-
log.debug(this.tag, "Killed dev server group");
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
resolveLocalChanges(repoRoot, shortId) {
|
|
565
|
-
// 1. Check uncommitted changes (staged + unstaged combined)
|
|
566
|
-
try {
|
|
567
|
-
const localChanges = execFileSync("git", ["diff", "HEAD"], {
|
|
568
|
-
cwd: repoRoot,
|
|
569
|
-
encoding: "utf-8",
|
|
570
|
-
timeout: 5_000,
|
|
571
|
-
});
|
|
572
|
-
if (localChanges) {
|
|
573
|
-
return { diff: localChanges, source: "uncommitted changes" };
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
catch {
|
|
577
|
-
log.warn(this.tag, "Failed to check uncommitted changes");
|
|
578
|
-
}
|
|
579
|
-
// 2. Search recent commits for card reference
|
|
580
|
-
try {
|
|
581
|
-
const matchingCommits = execFileSync("git", ["log", "--format=%H", "-20", `--grep=#${shortId}`], { cwd: repoRoot, encoding: "utf-8", timeout: 10_000 }).trim();
|
|
582
|
-
if (matchingCommits) {
|
|
583
|
-
const hashes = matchingCommits
|
|
584
|
-
.split("\n")
|
|
585
|
-
.filter((h) => /^[0-9a-f]{4,40}$/i.test(h));
|
|
586
|
-
if (hashes.length === 0)
|
|
587
|
-
return null;
|
|
588
|
-
log.info(this.tag, `Found ${hashes.length} commit(s) referencing #${shortId}`);
|
|
589
|
-
// Generate a combined diff from all matching commits
|
|
590
|
-
const diffs = [];
|
|
591
|
-
for (const hash of hashes) {
|
|
592
|
-
try {
|
|
593
|
-
const commitDiff = execFileSync("git", ["diff", `${hash}~1..${hash}`], { cwd: repoRoot, encoding: "utf-8", timeout: 30_000 });
|
|
594
|
-
if (commitDiff)
|
|
595
|
-
diffs.push(commitDiff);
|
|
596
|
-
}
|
|
597
|
-
catch {
|
|
598
|
-
log.warn(this.tag, `Failed to diff commit ${hash}`);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
if (diffs.length > 0) {
|
|
602
|
-
return {
|
|
603
|
-
diff: diffs.join("\n"),
|
|
604
|
-
source: `${diffs.length} commit(s) matching #${shortId}`,
|
|
605
|
-
};
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
catch {
|
|
610
|
-
log.warn(this.tag, "Failed to search recent commits");
|
|
611
|
-
}
|
|
612
|
-
return null;
|
|
613
|
-
}
|
|
614
|
-
cleanup() {
|
|
615
|
-
if (this.timeoutTimer) {
|
|
616
|
-
clearTimeout(this.timeoutTimer);
|
|
617
|
-
this.timeoutTimer = null;
|
|
618
|
-
}
|
|
619
|
-
this.stopHeartbeat();
|
|
620
|
-
this.killDevServer();
|
|
621
|
-
// Clean up worktree on error (only if we created one — skip in local mode)
|
|
622
|
-
if (this.worktreePath && this.state === "error" && this.branchName) {
|
|
623
|
-
try {
|
|
624
|
-
cleanupWorktree(this.worktreePath, this.branchName);
|
|
625
|
-
}
|
|
626
|
-
catch {
|
|
627
|
-
log.warn(this.tag, "Failed to cleanup review worktree");
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
this.process = null;
|
|
631
|
-
this.cardId = null;
|
|
632
|
-
this.branchName = null;
|
|
633
|
-
this.worktreePath = null;
|
|
634
|
-
this.startedAt = null;
|
|
635
|
-
this.runId = null;
|
|
636
|
-
this.lastSessionStats = null;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Checkout an existing remote branch into a worktree for review.
|
|
3
|
-
* Unlike createWorktree() which creates a new branch, this checks out
|
|
4
|
-
* an existing branch that was pushed by the implementation worker.
|
|
5
|
-
*/
|
|
6
|
-
export declare function checkoutExistingBranch(basePath: string, branchName: string): string;
|
|
7
|
-
/**
|
|
8
|
-
* Extract branch name from card description's completion summary.
|
|
9
|
-
* Looks for: Branch: `agent/...`
|
|
10
|
-
* Validates that the extracted name contains only safe characters.
|
|
11
|
-
*/
|
|
12
|
-
export declare function extractBranchFromDescription(description: string | null | undefined): string | null;
|