@gethmy/agent 1.0.9 → 1.1.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/README.md +67 -16
- package/dist/__tests__/budget.test.d.ts +1 -0
- package/dist/__tests__/budget.test.js +94 -0
- package/dist/__tests__/config-validation.test.d.ts +1 -0
- package/dist/__tests__/config-validation.test.js +65 -0
- package/dist/__tests__/dev-server-readiness.test.d.ts +1 -0
- package/dist/__tests__/dev-server-readiness.test.js +26 -0
- package/dist/__tests__/http-server.test.d.ts +1 -0
- package/dist/__tests__/http-server.test.js +115 -0
- package/dist/__tests__/log.test.d.ts +1 -0
- package/dist/__tests__/log.test.js +115 -0
- package/dist/__tests__/process-group.test.d.ts +1 -0
- package/dist/__tests__/process-group.test.js +68 -0
- package/dist/__tests__/reconcile-heartbeat.test.d.ts +1 -0
- package/dist/__tests__/reconcile-heartbeat.test.js +116 -0
- package/dist/__tests__/recovery.test.d.ts +1 -0
- package/dist/__tests__/recovery.test.js +126 -0
- package/dist/__tests__/review-parser.test.d.ts +1 -0
- package/dist/__tests__/review-parser.test.js +65 -0
- package/dist/__tests__/state-store.test.d.ts +1 -0
- package/dist/__tests__/state-store.test.js +132 -0
- package/dist/__tests__/transitions.test.d.ts +1 -0
- package/dist/__tests__/transitions.test.js +130 -0
- package/dist/__tests__/worktree-gc.test.d.ts +1 -0
- package/dist/__tests__/worktree-gc.test.js +137 -0
- package/dist/budget.d.ts +45 -0
- package/dist/budget.js +94 -0
- package/dist/cli.d.ts +15 -1
- package/dist/cli.js +239 -1
- package/dist/completion.d.ts +9 -0
- package/dist/completion.js +28 -2
- package/dist/config-validation.d.ts +18 -0
- package/dist/config-validation.js +66 -0
- package/dist/config.js +12 -0
- package/dist/http-server.d.ts +79 -0
- package/dist/http-server.js +115 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +125 -10
- package/dist/log.d.ts +29 -5
- package/dist/log.js +80 -15
- package/dist/pool.d.ts +27 -2
- package/dist/pool.js +69 -4
- package/dist/process-group.d.ts +26 -0
- package/dist/process-group.js +72 -0
- package/dist/progress-tracker.js +2 -0
- package/dist/queue.d.ts +2 -0
- package/dist/queue.js +4 -0
- package/dist/reconcile.d.ts +15 -1
- package/dist/reconcile.js +63 -2
- package/dist/recovery.d.ts +30 -0
- package/dist/recovery.js +136 -0
- package/dist/review-completion.d.ts +12 -4
- package/dist/review-completion.js +158 -49
- package/dist/review-worker.d.ts +9 -2
- package/dist/review-worker.js +182 -78
- package/dist/run-log.d.ts +6 -0
- package/dist/run-log.js +19 -0
- package/dist/state-store.d.ts +72 -0
- package/dist/state-store.js +216 -0
- package/dist/transitions.d.ts +57 -0
- package/dist/transitions.js +131 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.js +19 -1
- package/dist/verification.d.ts +17 -0
- package/dist/verification.js +71 -10
- package/dist/watcher.d.ts +2 -0
- package/dist/watcher.js +11 -0
- package/dist/worker.d.ts +9 -2
- package/dist/worker.js +168 -47
- package/dist/worktree-gc.d.ts +39 -0
- package/dist/worktree-gc.js +139 -0
- package/package.json +1 -1
package/dist/review-worker.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { execFileSync
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
|
|
3
|
+
import { buildTokenPayload } from "./completion.js";
|
|
3
4
|
import { log } from "./log.js";
|
|
5
|
+
import { signalGroup, spawnInGroup, terminateGroup } from "./process-group.js";
|
|
4
6
|
import { ProgressTracker } from "./progress-tracker.js";
|
|
5
7
|
import { parseReviewOutput, runReviewCompletion } from "./review-completion.js";
|
|
6
8
|
import { buildReviewSystemPrompt, buildReviewUserPrompt, } from "./review-prompt.js";
|
|
7
9
|
import { checkoutExistingBranch, extractBranchFromDescription, } from "./review-worktree.js";
|
|
10
|
+
import { openRunLog } from "./run-log.js";
|
|
11
|
+
import { newRunId } from "./state-store.js";
|
|
8
12
|
import { StreamParser } from "./stream-parser.js";
|
|
13
|
+
import { runTransition, TransitionError } from "./transitions.js";
|
|
9
14
|
import { AGENT_NAME, agentIdentifier, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR, } from "./types.js";
|
|
10
|
-
import { waitForDevServer } from "./verification.js";
|
|
15
|
+
import { DevServerReadinessError, probeDevServer, waitForDevServer, } from "./verification.js";
|
|
11
16
|
import { cleanupWorktree } from "./worktree.js";
|
|
12
17
|
const TAG = "review-worker";
|
|
13
18
|
const CANCEL_SIGINT_TIMEOUT = 30_000;
|
|
@@ -16,6 +21,7 @@ export class ReviewWorker {
|
|
|
16
21
|
config;
|
|
17
22
|
client;
|
|
18
23
|
onDone;
|
|
24
|
+
stateStore;
|
|
19
25
|
id;
|
|
20
26
|
state = "idle";
|
|
21
27
|
cardId = null;
|
|
@@ -25,14 +31,52 @@ export class ReviewWorker {
|
|
|
25
31
|
process = null;
|
|
26
32
|
devServerProcess = null;
|
|
27
33
|
timeoutTimer = null;
|
|
34
|
+
heartbeatTimer = null;
|
|
28
35
|
progressTracker = null;
|
|
36
|
+
lastSessionStats = null;
|
|
29
37
|
aborted = false;
|
|
30
|
-
|
|
38
|
+
runId = null;
|
|
39
|
+
constructor(id, config, client, _userEmail, onDone, stateStore) {
|
|
31
40
|
this.config = config;
|
|
32
41
|
this.client = client;
|
|
33
42
|
this.onDone = onDone;
|
|
43
|
+
this.stateStore = stateStore;
|
|
34
44
|
this.id = id;
|
|
35
45
|
}
|
|
46
|
+
startHeartbeat() {
|
|
47
|
+
this.stopHeartbeat();
|
|
48
|
+
const interval = this.config.timing.heartbeatMs;
|
|
49
|
+
this.heartbeatTimer = setInterval(() => {
|
|
50
|
+
if (this.runId) {
|
|
51
|
+
this.stateStore.heartbeat(this.runId).catch(() => {
|
|
52
|
+
/* retry next tick */
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}, interval);
|
|
56
|
+
// Don't block event loop shutdown for a pending heartbeat.
|
|
57
|
+
this.heartbeatTimer.unref();
|
|
58
|
+
}
|
|
59
|
+
stopHeartbeat() {
|
|
60
|
+
if (this.heartbeatTimer) {
|
|
61
|
+
clearInterval(this.heartbeatTimer);
|
|
62
|
+
this.heartbeatTimer = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async recordPhase(phase) {
|
|
66
|
+
if (!this.runId)
|
|
67
|
+
return;
|
|
68
|
+
try {
|
|
69
|
+
await this.stateStore.updateRun(this.runId, {
|
|
70
|
+
phase,
|
|
71
|
+
lastHeartbeatAt: Date.now(),
|
|
72
|
+
worktreePath: this.worktreePath,
|
|
73
|
+
branchName: this.branchName,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
log.warn(this.tag, `state store updateRun failed: ${err instanceof Error ? err.message : err}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
36
80
|
get tag() {
|
|
37
81
|
return `${TAG}:${this.id}`;
|
|
38
82
|
}
|
|
@@ -56,10 +100,29 @@ export class ReviewWorker {
|
|
|
56
100
|
this.aborted = false;
|
|
57
101
|
this.cardId = card.id;
|
|
58
102
|
this.startedAt = Date.now();
|
|
103
|
+
this.runId = newRunId();
|
|
59
104
|
try {
|
|
60
105
|
// --- PREPARING ---
|
|
61
106
|
this.state = "preparing";
|
|
62
107
|
log.info(this.tag, `Preparing review for #${card.short_id} "${card.title}"`);
|
|
108
|
+
this.startHeartbeat();
|
|
109
|
+
await this.stateStore.insertRun({
|
|
110
|
+
runId: this.runId,
|
|
111
|
+
cardId: card.id,
|
|
112
|
+
cardShortId: card.short_id,
|
|
113
|
+
pipeline: "review",
|
|
114
|
+
workerId: this.id,
|
|
115
|
+
sessionId: null,
|
|
116
|
+
worktreePath: null,
|
|
117
|
+
branchName: null,
|
|
118
|
+
daemonPid: process.pid,
|
|
119
|
+
phase: "preparing",
|
|
120
|
+
startedAt: this.startedAt,
|
|
121
|
+
lastHeartbeatAt: this.startedAt,
|
|
122
|
+
endedAt: null,
|
|
123
|
+
status: "active",
|
|
124
|
+
costCents: 0,
|
|
125
|
+
});
|
|
63
126
|
// Extract branch name from card description
|
|
64
127
|
this.branchName = extractBranchFromDescription(card.description);
|
|
65
128
|
const localMode = !this.branchName;
|
|
@@ -75,31 +138,9 @@ export class ReviewWorker {
|
|
|
75
138
|
// Try to find reviewable changes
|
|
76
139
|
const resolved = this.resolveLocalChanges(this.worktreePath, card.short_id);
|
|
77
140
|
if (!resolved) {
|
|
78
|
-
// No changes found —
|
|
79
|
-
log.info(this.tag, `No local changes found for #${card.short_id} —
|
|
80
|
-
|
|
81
|
-
const AGENT_NOTE_SEPARATOR = "\n\n---\n**Agent Note: No branch found**";
|
|
82
|
-
const existingDesc = card.description ?? "";
|
|
83
|
-
const cleanDesc = existingDesc.includes(AGENT_NOTE_SEPARATOR)
|
|
84
|
-
? existingDesc.slice(0, existingDesc.indexOf(AGENT_NOTE_SEPARATOR))
|
|
85
|
-
: existingDesc;
|
|
86
|
-
const note = [
|
|
87
|
-
cleanDesc,
|
|
88
|
-
"",
|
|
89
|
-
"---",
|
|
90
|
-
"**Agent Note: No branch found**",
|
|
91
|
-
"The review agent could not find a branch reference (`Branch: ...`) in this card's description,",
|
|
92
|
-
`and no local uncommitted changes or recent commits referencing #${card.short_id} were found.`,
|
|
93
|
-
"",
|
|
94
|
-
"Please either:",
|
|
95
|
-
"- Push the implementation branch and add `Branch: your-branch-name` to the description",
|
|
96
|
-
'- Or move this card back to "To Do" for the implement worker to pick up',
|
|
97
|
-
].join("\n");
|
|
98
|
-
await Promise.all([
|
|
99
|
-
this.client.updateCard(card.id, { description: note }),
|
|
100
|
-
addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR),
|
|
101
|
-
moveCardToColumn(this.client, card, this.config.review.failColumn),
|
|
102
|
-
]);
|
|
141
|
+
// No changes found — mark for human review and leave in current column
|
|
142
|
+
log.info(this.tag, `No local changes found for #${card.short_id} — marking for human review (staying in Review)`);
|
|
143
|
+
await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
|
|
103
144
|
return;
|
|
104
145
|
}
|
|
105
146
|
log.info(this.tag, `Found local changes via ${resolved.source} for #${card.short_id}`);
|
|
@@ -129,6 +170,7 @@ export class ReviewWorker {
|
|
|
129
170
|
return;
|
|
130
171
|
// --- REVIEWING ---
|
|
131
172
|
this.state = "running";
|
|
173
|
+
await this.recordPhase("running");
|
|
132
174
|
if (!this.worktreePath) {
|
|
133
175
|
throw new Error("worktreePath not set before review phase");
|
|
134
176
|
}
|
|
@@ -137,8 +179,12 @@ export class ReviewWorker {
|
|
|
137
179
|
const cwd = this.worktreePath;
|
|
138
180
|
if (!localMode) {
|
|
139
181
|
log.info(this.tag, `Starting dev server on port ${port}...`);
|
|
140
|
-
this.devServerProcess =
|
|
182
|
+
this.devServerProcess = spawnInGroup("bun", ["run", "dev", "--", "--port", String(port)], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
183
|
+
// Invariant I2: review only proceeds with a proven-live dev server.
|
|
184
|
+
// waitForDevServer rejects on timeout / exit; probeDevServer verifies
|
|
185
|
+
// the port actually answers HTTP.
|
|
141
186
|
await waitForDevServer(this.devServerProcess, 30_000);
|
|
187
|
+
await probeDevServer(port);
|
|
142
188
|
log.info(this.tag, `Dev server ready on port ${port}`);
|
|
143
189
|
}
|
|
144
190
|
if (this.aborted)
|
|
@@ -182,13 +228,17 @@ export class ReviewWorker {
|
|
|
182
228
|
// Set up progress tracking
|
|
183
229
|
this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
|
|
184
230
|
// Spawn Claude CLI for review with streaming
|
|
185
|
-
const stdout = await this.spawnClaude(userPrompt, systemPrompt, this.progressTracker);
|
|
231
|
+
const stdout = await this.spawnClaude(userPrompt, systemPrompt, this.progressTracker, card.short_id);
|
|
232
|
+
// Capture session stats before stopping tracker
|
|
233
|
+
this.lastSessionStats = this.progressTracker?.stats ?? null;
|
|
186
234
|
this.progressTracker?.stop();
|
|
187
235
|
this.progressTracker = null;
|
|
236
|
+
const sessionStats = this.lastSessionStats;
|
|
188
237
|
if (this.aborted)
|
|
189
238
|
return;
|
|
190
239
|
// --- COMPLETING ---
|
|
191
240
|
this.state = "completing";
|
|
241
|
+
await this.recordPhase("completing");
|
|
192
242
|
log.info(this.tag, `Claude review finished for #${card.short_id}`);
|
|
193
243
|
// Kill dev server (only if we started one)
|
|
194
244
|
if (!localMode) {
|
|
@@ -205,29 +255,75 @@ export class ReviewWorker {
|
|
|
205
255
|
progressPercent: 80,
|
|
206
256
|
});
|
|
207
257
|
// Run review completion pipeline
|
|
208
|
-
await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName);
|
|
258
|
+
await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats);
|
|
209
259
|
}
|
|
210
260
|
catch (err) {
|
|
211
261
|
this.state = "error";
|
|
212
262
|
const msg = err instanceof Error ? err.message : String(err);
|
|
213
263
|
log.error(this.tag, `Error reviewing #${card.short_id}: ${msg}`);
|
|
214
|
-
// End session as paused on error
|
|
264
|
+
// End session as paused on error. Retried via runTransition so a
|
|
265
|
+
// transient API failure no longer orphans the session.
|
|
215
266
|
try {
|
|
216
|
-
|
|
267
|
+
const stats = this.lastSessionStats ?? this.progressTracker?.stats;
|
|
268
|
+
await runTransition(this.client, card, {
|
|
269
|
+
endSession: {
|
|
270
|
+
status: "paused",
|
|
271
|
+
...buildTokenPayload(stats),
|
|
272
|
+
},
|
|
273
|
+
});
|
|
217
274
|
}
|
|
218
|
-
catch {
|
|
219
|
-
|
|
275
|
+
catch (tErr) {
|
|
276
|
+
log.error(this.tag, `endAgentSession unrecoverable on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
|
|
220
277
|
}
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
278
|
+
// Dev server readiness failures are infrastructure errors, not
|
|
279
|
+
// implementation errors — the code under review may be perfectly
|
|
280
|
+
// fine. Invariant I2: never approve without a live server, but
|
|
281
|
+
// also never bounce the card back to "To Do" for rework. Keep
|
|
282
|
+
// it in Review with a "Need Review" label so a human steps in.
|
|
283
|
+
if (err instanceof DevServerReadinessError) {
|
|
284
|
+
try {
|
|
285
|
+
await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
|
|
286
|
+
log.info(this.tag, `#${card.short_id} kept in Review — dev server unavailable, human review needed`);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
log.warn(this.tag, "Failed to add Need Review label after dev-server failure");
|
|
290
|
+
}
|
|
225
291
|
}
|
|
226
|
-
|
|
227
|
-
|
|
292
|
+
else {
|
|
293
|
+
// Move card out of Review to break the re-enqueue loop for other errors
|
|
294
|
+
try {
|
|
295
|
+
await moveCardToColumn(this.client, card, this.config.review.failColumn);
|
|
296
|
+
log.info(this.tag, `Moved #${card.short_id} to "${this.config.review.failColumn}" after error`);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
log.warn(this.tag, "Failed to move card to fail column after error");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (this.runId) {
|
|
303
|
+
try {
|
|
304
|
+
await this.stateStore.endRun(this.runId, this.state === "error" ? "paused" : "completed");
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// best-effort
|
|
308
|
+
}
|
|
228
309
|
}
|
|
229
310
|
}
|
|
230
311
|
finally {
|
|
312
|
+
if (this.runId) {
|
|
313
|
+
try {
|
|
314
|
+
const run = this.stateStore.getRun(this.runId);
|
|
315
|
+
if (run && run.endedAt === null) {
|
|
316
|
+
// Cancelled runs are paused, not completed — they never
|
|
317
|
+
// produced a verdict so treating them as success would be
|
|
318
|
+
// a lie to the budget/attempt bookkeeping.
|
|
319
|
+
const status = this.state === "error" || this.aborted ? "paused" : "completed";
|
|
320
|
+
await this.stateStore.endRun(this.runId, status);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// best-effort
|
|
325
|
+
}
|
|
326
|
+
}
|
|
231
327
|
this.cleanup();
|
|
232
328
|
this.state = "idle";
|
|
233
329
|
this.onDone(this);
|
|
@@ -240,7 +336,7 @@ export class ReviewWorker {
|
|
|
240
336
|
if (!this.isActive || !this.process || this.process.killed)
|
|
241
337
|
return;
|
|
242
338
|
log.info(this.tag, `Pausing review on ${this.cardId}`);
|
|
243
|
-
this.process
|
|
339
|
+
signalGroup(this.process, "SIGSTOP");
|
|
244
340
|
if (this.timeoutTimer) {
|
|
245
341
|
clearTimeout(this.timeoutTimer);
|
|
246
342
|
this.timeoutTimer = null;
|
|
@@ -266,7 +362,7 @@ export class ReviewWorker {
|
|
|
266
362
|
if (!this.isActive || !this.process || this.process.killed)
|
|
267
363
|
return;
|
|
268
364
|
log.info(this.tag, `Resuming review on ${this.cardId}`);
|
|
269
|
-
this.process
|
|
365
|
+
signalGroup(this.process, "SIGCONT");
|
|
270
366
|
this.timeoutTimer = setTimeout(() => {
|
|
271
367
|
log.warn(this.tag, `Timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
|
|
272
368
|
this.cancel();
|
|
@@ -294,6 +390,9 @@ export class ReviewWorker {
|
|
|
294
390
|
this.aborted = true;
|
|
295
391
|
this.state = "cancelling";
|
|
296
392
|
log.info(this.tag, `Cancelling review on ${this.cardId}`);
|
|
393
|
+
// Capture tokens/cost snapshot before stopping the tracker so a
|
|
394
|
+
// cancelled run still persists its usage to the session.
|
|
395
|
+
const snapshotStats = this.lastSessionStats ?? this.progressTracker?.stats;
|
|
297
396
|
// Stop progress tracking
|
|
298
397
|
if (this.progressTracker) {
|
|
299
398
|
this.progressTracker?.stop();
|
|
@@ -301,34 +400,27 @@ export class ReviewWorker {
|
|
|
301
400
|
}
|
|
302
401
|
// Kill dev server first
|
|
303
402
|
this.killDevServer();
|
|
304
|
-
// Then kill Claude process with escalating signals
|
|
403
|
+
// Then kill Claude process group with escalating signals
|
|
305
404
|
if (this.process && !this.process.killed) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const sigintDead = await this.waitForExit(CANCEL_SIGINT_TIMEOUT);
|
|
311
|
-
if (!sigintDead) {
|
|
312
|
-
this.process.kill("SIGTERM");
|
|
313
|
-
log.debug(this.tag, "Sent SIGTERM to Claude");
|
|
314
|
-
const sigtermDead = await this.waitForExit(CANCEL_SIGTERM_TIMEOUT);
|
|
315
|
-
if (!sigtermDead) {
|
|
316
|
-
this.process.kill("SIGKILL");
|
|
317
|
-
log.warn(this.tag, "Sent SIGKILL to Claude");
|
|
318
|
-
}
|
|
319
|
-
}
|
|
405
|
+
await terminateGroup(this.process, {
|
|
406
|
+
sigintTimeoutMs: CANCEL_SIGINT_TIMEOUT,
|
|
407
|
+
sigtermTimeoutMs: CANCEL_SIGTERM_TIMEOUT,
|
|
408
|
+
});
|
|
320
409
|
}
|
|
321
410
|
// End agent session as paused
|
|
322
411
|
if (this.cardId) {
|
|
323
412
|
try {
|
|
324
|
-
await this.client.endAgentSession(this.cardId, {
|
|
413
|
+
await this.client.endAgentSession(this.cardId, {
|
|
414
|
+
status: "paused",
|
|
415
|
+
...buildTokenPayload(snapshotStats),
|
|
416
|
+
});
|
|
325
417
|
}
|
|
326
418
|
catch {
|
|
327
419
|
// best-effort
|
|
328
420
|
}
|
|
329
421
|
}
|
|
330
422
|
}
|
|
331
|
-
spawnClaude(prompt, systemPrompt, tracker) {
|
|
423
|
+
spawnClaude(prompt, systemPrompt, tracker, shortId) {
|
|
332
424
|
return new Promise((resolve, reject) => {
|
|
333
425
|
const args = [
|
|
334
426
|
"--output-format",
|
|
@@ -346,7 +438,13 @@ export class ReviewWorker {
|
|
|
346
438
|
prompt,
|
|
347
439
|
];
|
|
348
440
|
log.info(this.tag, `Spawning review: claude ${args.slice(0, 5).join(" ")} ...`);
|
|
349
|
-
|
|
441
|
+
const runLog = openRunLog(this.tag, this.runId, shortId);
|
|
442
|
+
if (runLog) {
|
|
443
|
+
log.info(this.tag, `Run log: ${runLog.path}`);
|
|
444
|
+
runLog.stream.write(`# run=${this.runId} card=#${shortId} pipeline=review started=${new Date().toISOString()}\n` +
|
|
445
|
+
`# args: ${args.slice(0, -2).join(" ")} -- <prompt:${prompt.length} chars>\n\n`);
|
|
446
|
+
}
|
|
447
|
+
this.process = spawnInGroup("claude", args, {
|
|
350
448
|
cwd: this.worktreePath,
|
|
351
449
|
stdio: ["ignore", "pipe", "pipe"],
|
|
352
450
|
});
|
|
@@ -357,13 +455,23 @@ export class ReviewWorker {
|
|
|
357
455
|
parser.on("text", (content) => {
|
|
358
456
|
textChunks.push(content);
|
|
359
457
|
});
|
|
458
|
+
parser.on("parse_error", (msg) => {
|
|
459
|
+
log.debug(this.tag, `Stream parse error (non-fatal): ${msg}`);
|
|
460
|
+
runLog?.stream.write(`\n[parse_error] ${msg}\n`);
|
|
461
|
+
});
|
|
360
462
|
// Attach parser to stdout (single consumer)
|
|
361
463
|
if (this.process.stdout) {
|
|
362
464
|
parser.attach(this.process.stdout);
|
|
465
|
+
if (runLog) {
|
|
466
|
+
this.process.stdout.on("data", (chunk) => {
|
|
467
|
+
runLog.stream.write(chunk);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
363
470
|
}
|
|
364
471
|
let stderr = "";
|
|
365
472
|
this.process.stderr?.on("data", (data) => {
|
|
366
473
|
stderr += data.toString();
|
|
474
|
+
runLog?.stream.write(`[stderr] ${data.toString()}`);
|
|
367
475
|
});
|
|
368
476
|
this.process.on("error", (err) => {
|
|
369
477
|
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
@@ -372,6 +480,14 @@ export class ReviewWorker {
|
|
|
372
480
|
this.process = null;
|
|
373
481
|
// Reconstruct text from stream parser text events
|
|
374
482
|
const stdout = textChunks.join("");
|
|
483
|
+
const stats = tracker.stats;
|
|
484
|
+
if (runLog) {
|
|
485
|
+
runLog.stream.write(`\n# exit code=${code} aborted=${this.aborted} ` +
|
|
486
|
+
`toolCalls=${stats?.toolCalls ?? 0} filesEdited=${stats?.filesEdited ?? 0} ` +
|
|
487
|
+
`cost=$${stats?.cost?.totalCostUsd.toFixed(4) ?? "0"} ` +
|
|
488
|
+
`textChars=${stdout.length} ended=${new Date().toISOString()}\n`);
|
|
489
|
+
runLog.stream.end();
|
|
490
|
+
}
|
|
375
491
|
if (this.aborted) {
|
|
376
492
|
resolve(stdout);
|
|
377
493
|
}
|
|
@@ -384,26 +500,11 @@ export class ReviewWorker {
|
|
|
384
500
|
});
|
|
385
501
|
});
|
|
386
502
|
}
|
|
387
|
-
waitForExit(timeout) {
|
|
388
|
-
return new Promise((resolve) => {
|
|
389
|
-
if (!this.process || this.process.killed) {
|
|
390
|
-
resolve(true);
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
const timer = setTimeout(() => {
|
|
394
|
-
resolve(false);
|
|
395
|
-
}, timeout);
|
|
396
|
-
this.process.once("close", () => {
|
|
397
|
-
clearTimeout(timer);
|
|
398
|
-
resolve(true);
|
|
399
|
-
});
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
503
|
killDevServer() {
|
|
403
504
|
if (this.devServerProcess && !this.devServerProcess.killed) {
|
|
404
|
-
this.devServerProcess
|
|
505
|
+
signalGroup(this.devServerProcess, "SIGTERM");
|
|
405
506
|
this.devServerProcess = null;
|
|
406
|
-
log.debug(this.tag, "Killed dev server");
|
|
507
|
+
log.debug(this.tag, "Killed dev server group");
|
|
407
508
|
}
|
|
408
509
|
}
|
|
409
510
|
resolveLocalChanges(repoRoot, shortId) {
|
|
@@ -461,6 +562,7 @@ export class ReviewWorker {
|
|
|
461
562
|
clearTimeout(this.timeoutTimer);
|
|
462
563
|
this.timeoutTimer = null;
|
|
463
564
|
}
|
|
565
|
+
this.stopHeartbeat();
|
|
464
566
|
this.killDevServer();
|
|
465
567
|
// Clean up worktree on error (only if we created one — skip in local mode)
|
|
466
568
|
if (this.worktreePath && this.state === "error" && this.branchName) {
|
|
@@ -476,5 +578,7 @@ export class ReviewWorker {
|
|
|
476
578
|
this.branchName = null;
|
|
477
579
|
this.worktreePath = null;
|
|
478
580
|
this.startedAt = null;
|
|
581
|
+
this.runId = null;
|
|
582
|
+
this.lastSessionStats = null;
|
|
479
583
|
}
|
|
480
584
|
}
|
package/dist/run-log.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createWriteStream, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
export function openRunLog(tag, runId, shortId) {
|
|
6
|
+
if (!runId)
|
|
7
|
+
return null;
|
|
8
|
+
try {
|
|
9
|
+
const dir = join(homedir(), ".harmony-mcp", "runs");
|
|
10
|
+
mkdirSync(dir, { recursive: true });
|
|
11
|
+
const path = join(dir, `${runId}-card-${shortId}.log`);
|
|
12
|
+
const stream = createWriteStream(path, { flags: "a" });
|
|
13
|
+
return { path, stream };
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
log.warn(tag, `Failed to open run log: ${err instanceof Error ? err.message : err}`);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type RunPipeline = "implement" | "review";
|
|
2
|
+
export type RunStatus = "active" | "completed" | "paused" | "orphaned";
|
|
3
|
+
export interface RunRecord {
|
|
4
|
+
runId: string;
|
|
5
|
+
cardId: string;
|
|
6
|
+
cardShortId: number;
|
|
7
|
+
pipeline: RunPipeline;
|
|
8
|
+
workerId: number;
|
|
9
|
+
sessionId: string | null;
|
|
10
|
+
worktreePath: string | null;
|
|
11
|
+
branchName: string | null;
|
|
12
|
+
daemonPid: number;
|
|
13
|
+
phase: string;
|
|
14
|
+
startedAt: number;
|
|
15
|
+
lastHeartbeatAt: number;
|
|
16
|
+
endedAt: number | null;
|
|
17
|
+
status: RunStatus;
|
|
18
|
+
costCents: number;
|
|
19
|
+
errorMessage?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CardRecord {
|
|
22
|
+
cardId: string;
|
|
23
|
+
attempts: number;
|
|
24
|
+
totalCostCents: number;
|
|
25
|
+
dlq: boolean;
|
|
26
|
+
dlqReason?: string;
|
|
27
|
+
lastAttemptAt: number | null;
|
|
28
|
+
lastOutcome: "success" | "failure" | null;
|
|
29
|
+
}
|
|
30
|
+
export declare function newRunId(): string;
|
|
31
|
+
export declare function defaultStatePath(): string;
|
|
32
|
+
/**
|
|
33
|
+
* Durable run-and-card state for the agent daemon. One writer (this daemon),
|
|
34
|
+
* so in-process serialization via a promise chain is sufficient — no file locks.
|
|
35
|
+
* Persists to JSON via write-to-tmp + rename, which is atomic on POSIX.
|
|
36
|
+
*/
|
|
37
|
+
export declare class StateStore {
|
|
38
|
+
private path;
|
|
39
|
+
private state;
|
|
40
|
+
private writeQueue;
|
|
41
|
+
constructor(path: string);
|
|
42
|
+
static open(path?: string): StateStore;
|
|
43
|
+
private load;
|
|
44
|
+
private persist;
|
|
45
|
+
/** Await any pending writes. Useful for tests and shutdown. */
|
|
46
|
+
flush(): Promise<void>;
|
|
47
|
+
setDaemon(daemonId: string, pid: number): Promise<void>;
|
|
48
|
+
getDaemon(): {
|
|
49
|
+
daemonId: string | null;
|
|
50
|
+
daemonPid: number | null;
|
|
51
|
+
daemonStartedAt: number | null;
|
|
52
|
+
};
|
|
53
|
+
insertRun(run: RunRecord): Promise<void>;
|
|
54
|
+
updateRun(runId: string, patch: Partial<RunRecord>): Promise<void>;
|
|
55
|
+
heartbeat(runId: string): Promise<void>;
|
|
56
|
+
endRun(runId: string, status: RunStatus, errorMessage?: string): Promise<void>;
|
|
57
|
+
getRun(runId: string): RunRecord | null;
|
|
58
|
+
getActiveRuns(): RunRecord[];
|
|
59
|
+
getRunsForCard(cardId: string): RunRecord[];
|
|
60
|
+
/** Trim completed runs older than `beforeTs` to keep the file small. */
|
|
61
|
+
purgeOldRuns(beforeTs: number): Promise<void>;
|
|
62
|
+
private ensureCard;
|
|
63
|
+
getCard(cardId: string): CardRecord | null;
|
|
64
|
+
incrementAttempt(cardId: string): Promise<number>;
|
|
65
|
+
recordOutcome(cardId: string, outcome: "success" | "failure"): Promise<void>;
|
|
66
|
+
addCost(cardId: string, cents: number): Promise<void>;
|
|
67
|
+
getDailyCostCents(date?: string): number;
|
|
68
|
+
markDlq(cardId: string, reason: string): Promise<void>;
|
|
69
|
+
clearDlq(cardId: string): Promise<void>;
|
|
70
|
+
isDlq(cardId: string): boolean;
|
|
71
|
+
listDlq(): CardRecord[];
|
|
72
|
+
}
|