@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.
Files changed (72) hide show
  1. package/README.md +67 -16
  2. package/dist/__tests__/budget.test.d.ts +1 -0
  3. package/dist/__tests__/budget.test.js +94 -0
  4. package/dist/__tests__/config-validation.test.d.ts +1 -0
  5. package/dist/__tests__/config-validation.test.js +65 -0
  6. package/dist/__tests__/dev-server-readiness.test.d.ts +1 -0
  7. package/dist/__tests__/dev-server-readiness.test.js +26 -0
  8. package/dist/__tests__/http-server.test.d.ts +1 -0
  9. package/dist/__tests__/http-server.test.js +115 -0
  10. package/dist/__tests__/log.test.d.ts +1 -0
  11. package/dist/__tests__/log.test.js +115 -0
  12. package/dist/__tests__/process-group.test.d.ts +1 -0
  13. package/dist/__tests__/process-group.test.js +68 -0
  14. package/dist/__tests__/reconcile-heartbeat.test.d.ts +1 -0
  15. package/dist/__tests__/reconcile-heartbeat.test.js +116 -0
  16. package/dist/__tests__/recovery.test.d.ts +1 -0
  17. package/dist/__tests__/recovery.test.js +126 -0
  18. package/dist/__tests__/review-parser.test.d.ts +1 -0
  19. package/dist/__tests__/review-parser.test.js +65 -0
  20. package/dist/__tests__/state-store.test.d.ts +1 -0
  21. package/dist/__tests__/state-store.test.js +132 -0
  22. package/dist/__tests__/transitions.test.d.ts +1 -0
  23. package/dist/__tests__/transitions.test.js +130 -0
  24. package/dist/__tests__/worktree-gc.test.d.ts +1 -0
  25. package/dist/__tests__/worktree-gc.test.js +137 -0
  26. package/dist/budget.d.ts +45 -0
  27. package/dist/budget.js +94 -0
  28. package/dist/cli.d.ts +15 -1
  29. package/dist/cli.js +239 -1
  30. package/dist/completion.d.ts +9 -0
  31. package/dist/completion.js +28 -2
  32. package/dist/config-validation.d.ts +18 -0
  33. package/dist/config-validation.js +66 -0
  34. package/dist/config.js +12 -0
  35. package/dist/http-server.d.ts +79 -0
  36. package/dist/http-server.js +115 -0
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.js +125 -10
  39. package/dist/log.d.ts +29 -5
  40. package/dist/log.js +80 -15
  41. package/dist/pool.d.ts +27 -2
  42. package/dist/pool.js +69 -4
  43. package/dist/process-group.d.ts +26 -0
  44. package/dist/process-group.js +72 -0
  45. package/dist/progress-tracker.js +2 -0
  46. package/dist/queue.d.ts +2 -0
  47. package/dist/queue.js +4 -0
  48. package/dist/reconcile.d.ts +15 -1
  49. package/dist/reconcile.js +63 -2
  50. package/dist/recovery.d.ts +30 -0
  51. package/dist/recovery.js +136 -0
  52. package/dist/review-completion.d.ts +12 -4
  53. package/dist/review-completion.js +158 -49
  54. package/dist/review-worker.d.ts +9 -2
  55. package/dist/review-worker.js +182 -78
  56. package/dist/run-log.d.ts +6 -0
  57. package/dist/run-log.js +19 -0
  58. package/dist/state-store.d.ts +72 -0
  59. package/dist/state-store.js +216 -0
  60. package/dist/transitions.d.ts +57 -0
  61. package/dist/transitions.js +131 -0
  62. package/dist/types.d.ts +23 -0
  63. package/dist/types.js +19 -1
  64. package/dist/verification.d.ts +17 -0
  65. package/dist/verification.js +71 -10
  66. package/dist/watcher.d.ts +2 -0
  67. package/dist/watcher.js +11 -0
  68. package/dist/worker.d.ts +9 -2
  69. package/dist/worker.js +168 -47
  70. package/dist/worktree-gc.d.ts +39 -0
  71. package/dist/worktree-gc.js +139 -0
  72. package/package.json +1 -1
@@ -1,13 +1,18 @@
1
- import { execFileSync, spawn } from "node:child_process";
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
- constructor(id, config, client, _userEmail, onDone) {
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 — post helpful note on the card and bail
79
- log.info(this.tag, `No local changes found for #${card.short_id} — posting note and moving to "${this.config.review.failColumn}"`);
80
- // Strip any previous agent note before appending a new one
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 = spawn("bun", ["run", "dev", "--", "--port", String(port)], { cwd, stdio: ["ignore", "pipe", "pipe"] });
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
- await this.client.endAgentSession(card.id, { status: "paused" });
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
- // best-effort
275
+ catch (tErr) {
276
+ log.error(this.tag, `endAgentSession unrecoverable on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
220
277
  }
221
- // Move card out of Review to break the re-enqueue loop
222
- try {
223
- await moveCardToColumn(this.client, card, this.config.review.failColumn);
224
- log.info(this.tag, `Moved #${card.short_id} to "${this.config.review.failColumn}" after error`);
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
- catch {
227
- log.warn(this.tag, "Failed to move card to fail column after error");
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.kill("SIGSTOP");
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.kill("SIGCONT");
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
- // Resume first in case the process is suspended
307
- this.process.kill("SIGCONT");
308
- this.process.kill("SIGINT");
309
- log.debug(this.tag, "Sent SIGINT to Claude");
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, { status: "paused" });
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
- this.process = spawn("claude", args, {
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.kill("SIGTERM");
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
  }
@@ -0,0 +1,6 @@
1
+ import { type WriteStream } from "node:fs";
2
+ export interface RunLog {
3
+ path: string;
4
+ stream: WriteStream;
5
+ }
6
+ export declare function openRunLog(tag: string, runId: string | null, shortId: number): RunLog | null;
@@ -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
+ }