@gethmy/agent 1.0.9 → 1.1.1

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 +2 -2
package/dist/worker.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
2
  import type { Card, Column, Label, Subtask } from "@harmony/shared";
3
+ import { type StateStore } from "./state-store.js";
3
4
  import { type AgentConfig, type WorkerState } from "./types.js";
4
5
  export declare class Worker {
5
6
  private config;
@@ -7,6 +8,7 @@ export declare class Worker {
7
8
  private onDone;
8
9
  private workspaceId;
9
10
  private projectId;
11
+ private stateStore;
10
12
  id: number;
11
13
  state: WorkerState;
12
14
  cardId: string | null;
@@ -15,11 +17,16 @@ export declare class Worker {
15
17
  startedAt: number | null;
16
18
  private process;
17
19
  private timeoutTimer;
20
+ private heartbeatTimer;
18
21
  private progressTracker;
19
22
  private lastSessionStats;
20
23
  private aborted;
21
24
  private sessionId;
22
- constructor(id: number, config: AgentConfig, client: HarmonyApiClient, _userEmail: string, onDone: (worker: Worker) => void, workspaceId: string, projectId: string);
25
+ private runId;
26
+ constructor(id: number, config: AgentConfig, client: HarmonyApiClient, _userEmail: string, onDone: (worker: Worker) => void, workspaceId: string, projectId: string, stateStore: StateStore);
27
+ private startHeartbeat;
28
+ private stopHeartbeat;
29
+ private recordPhase;
23
30
  get tag(): string;
24
31
  get isIdle(): boolean;
25
32
  get isActive(): boolean;
@@ -28,6 +35,7 @@ export declare class Worker {
28
35
  * PREPARING → RUNNING → COMPLETING → IDLE
29
36
  */
30
37
  run(card: Card, column: Column, labels: Label[], subtasks: Subtask[]): Promise<void>;
38
+ private recordOutcome;
31
39
  /**
32
40
  * Pause the current work by suspending the Claude process (SIGSTOP).
33
41
  */
@@ -41,6 +49,5 @@ export declare class Worker {
41
49
  */
42
50
  cancel(): Promise<void>;
43
51
  private spawnClaude;
44
- private waitForExit;
45
52
  private cleanup;
46
53
  }
package/dist/worker.js CHANGED
@@ -1,10 +1,13 @@
1
- import { spawn } from "node:child_process";
2
1
  import { moveCardAndAddLabel } from "./board-helpers.js";
3
- import { runCompletion } from "./completion.js";
2
+ import { buildTokenPayload, runCompletion, } from "./completion.js";
4
3
  import { log } from "./log.js";
4
+ import { signalGroup, spawnInGroup, terminateGroup } from "./process-group.js";
5
5
  import { ProgressTracker } from "./progress-tracker.js";
6
6
  import { buildPrompt } from "./prompt.js";
7
+ import { openRunLog } from "./run-log.js";
8
+ import { newRunId } from "./state-store.js";
7
9
  import { StreamParser } from "./stream-parser.js";
10
+ import { runTransition, TransitionError } from "./transitions.js";
8
11
  import { AGENT_NAME, agentIdentifier, } from "./types.js";
9
12
  import { cleanupWorktree, createWorktree, makeBranchName } from "./worktree.js";
10
13
  const TAG = "worker";
@@ -16,6 +19,7 @@ export class Worker {
16
19
  onDone;
17
20
  workspaceId;
18
21
  projectId;
22
+ stateStore;
19
23
  id;
20
24
  state = "idle";
21
25
  cardId = null;
@@ -24,18 +28,56 @@ export class Worker {
24
28
  startedAt = null;
25
29
  process = null;
26
30
  timeoutTimer = null;
31
+ heartbeatTimer = null;
27
32
  progressTracker = null;
28
33
  lastSessionStats;
29
34
  aborted = false;
30
35
  sessionId = null;
31
- constructor(id, config, client, _userEmail, onDone, workspaceId, projectId) {
36
+ runId = null;
37
+ constructor(id, config, client, _userEmail, onDone, workspaceId, projectId, stateStore) {
32
38
  this.config = config;
33
39
  this.client = client;
34
40
  this.onDone = onDone;
35
41
  this.workspaceId = workspaceId;
36
42
  this.projectId = projectId;
43
+ this.stateStore = stateStore;
37
44
  this.id = id;
38
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
+ /* next tick will retry */
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
+ sessionId: this.sessionId,
75
+ });
76
+ }
77
+ catch (err) {
78
+ log.warn(this.tag, `state store updateRun failed: ${err instanceof Error ? err.message : err}`);
79
+ }
80
+ }
39
81
  get tag() {
40
82
  return `${TAG}:${this.id}`;
41
83
  }
@@ -56,11 +98,36 @@ export class Worker {
56
98
  this.aborted = false;
57
99
  this.cardId = card.id;
58
100
  this.startedAt = Date.now();
101
+ this.runId = newRunId();
59
102
  try {
60
103
  // --- PREPARING ---
61
104
  this.state = "preparing";
62
105
  this.branchName = makeBranchName(card.short_id, card.title);
63
106
  log.info(this.tag, `Preparing #${card.short_id} "${card.title}"`);
107
+ // Per-card attempt counter resets on success; DLQ triggers off it.
108
+ await this.stateStore.incrementAttempt(card.id);
109
+ // Start the heartbeat loop so the reconciler knows this run is
110
+ // still alive even if no phase transitions happen for a while
111
+ // (Claude can spend 5+ minutes in one tool call).
112
+ this.startHeartbeat();
113
+ // Record this run durably so we can recover on crash.
114
+ await this.stateStore.insertRun({
115
+ runId: this.runId,
116
+ cardId: card.id,
117
+ cardShortId: card.short_id,
118
+ pipeline: "implement",
119
+ workerId: this.id,
120
+ sessionId: null,
121
+ worktreePath: null,
122
+ branchName: this.branchName,
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
+ });
64
131
  // Start agent session and make it visible on the board
65
132
  const { session } = await this.client.startAgentSession(card.id, {
66
133
  agentIdentifier: agentIdentifier(this.id),
@@ -76,6 +143,7 @@ export class Worker {
76
143
  log.warn(TAG, "startAgentSession returned no session id");
77
144
  }
78
145
  this.sessionId = sid;
146
+ await this.recordPhase("preparing");
79
147
  // Move card to "In Progress" and add "agent" label so the board shows the progress ring
80
148
  const moved = await moveCardAndAddLabel(this.client, card, "In Progress", "agent");
81
149
  if (!moved) {
@@ -89,6 +157,7 @@ export class Worker {
89
157
  return;
90
158
  // --- RUNNING ---
91
159
  this.state = "running";
160
+ await this.recordPhase("running");
92
161
  const enriched = {
93
162
  card,
94
163
  column,
@@ -115,6 +184,7 @@ export class Worker {
115
184
  return;
116
185
  // --- VERIFYING + COMPLETING ---
117
186
  this.state = "verifying";
187
+ await this.recordPhase("verifying");
118
188
  log.info(this.tag, `Claude finished for #${card.short_id}, running verification & completion`);
119
189
  await this.client.updateAgentProgress(card.id, {
120
190
  agentIdentifier: agentIdentifier(this.id),
@@ -124,28 +194,79 @@ export class Worker {
124
194
  progressPercent: 75,
125
195
  });
126
196
  this.state = "completing";
197
+ await this.recordPhase("completing");
127
198
  await runCompletion(this.client, card, this.branchName, this.worktreePath, this.config, this.id, this.lastSessionStats);
128
199
  }
129
200
  catch (err) {
130
201
  this.state = "error";
131
202
  const msg = err instanceof Error ? err.message : String(err);
132
203
  log.error(this.tag, `Error on #${card.short_id}: ${msg}`);
133
- // End session as paused on error
204
+ // End session as paused. Retried — a transient API blip here used
205
+ // to orphan the session and leave the card stuck with a progress ring.
134
206
  try {
135
- await this.client.endAgentSession(card.id, {
136
- status: "paused",
207
+ await runTransition(this.client, card, {
208
+ endSession: {
209
+ status: "paused",
210
+ ...buildTokenPayload(this.lastSessionStats),
211
+ },
137
212
  });
138
213
  }
139
- catch {
140
- // best-effort
214
+ catch (tErr) {
215
+ log.error(this.tag, `endAgentSession unrecoverable on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
216
+ }
217
+ if (this.runId) {
218
+ try {
219
+ await this.stateStore.endRun(this.runId, "paused", msg);
220
+ }
221
+ catch {
222
+ // state-store best-effort; already persisted on last heartbeat
223
+ }
224
+ await this.recordOutcome(card.id, "failure");
141
225
  }
142
226
  }
143
227
  finally {
228
+ // Only bookkeep success when we actually succeeded. "cancelling"
229
+ // and aborted runs are failures/user-initiated stops, not wins —
230
+ // counting them as success would reset attempts and mask real
231
+ // failure loops.
232
+ const succeeded = this.runId && this.state !== "error" && !this.aborted;
233
+ if (succeeded) {
234
+ try {
235
+ await this.stateStore.endRun(this.runId, "completed");
236
+ }
237
+ catch {
238
+ // best-effort — state store failures don't block worker exit
239
+ }
240
+ await this.recordOutcome(card.id, "success");
241
+ }
242
+ else if (this.runId && this.aborted) {
243
+ // Cancelled run: don't touch attempts counter, but close the
244
+ // state store row so it isn't mistaken for a live run.
245
+ try {
246
+ await this.stateStore.endRun(this.runId, "paused", "cancelled");
247
+ }
248
+ catch {
249
+ // best-effort
250
+ }
251
+ }
144
252
  this.cleanup();
145
253
  this.state = "idle";
146
254
  this.onDone(this);
147
255
  }
148
256
  }
257
+ async recordOutcome(cardId, outcome) {
258
+ try {
259
+ const cost = this.lastSessionStats?.cost;
260
+ if (cost) {
261
+ const cents = Math.round(cost.totalCostUsd * 100);
262
+ await this.stateStore.addCost(cardId, cents);
263
+ }
264
+ await this.stateStore.recordOutcome(cardId, outcome);
265
+ }
266
+ catch (err) {
267
+ log.warn(this.tag, `recordOutcome(${outcome}) failed: ${err instanceof Error ? err.message : err}`);
268
+ }
269
+ }
149
270
  /**
150
271
  * Pause the current work by suspending the Claude process (SIGSTOP).
151
272
  */
@@ -153,7 +274,7 @@ export class Worker {
153
274
  if (!this.isActive || !this.process || this.process.killed)
154
275
  return;
155
276
  log.info(this.tag, `Pausing work on ${this.cardId}`);
156
- this.process.kill("SIGSTOP");
277
+ signalGroup(this.process, "SIGSTOP");
157
278
  // Pause the timeout timer
158
279
  if (this.timeoutTimer) {
159
280
  clearTimeout(this.timeoutTimer);
@@ -180,7 +301,7 @@ export class Worker {
180
301
  if (!this.isActive || !this.process || this.process.killed)
181
302
  return;
182
303
  log.info(this.tag, `Resuming work on ${this.cardId}`);
183
- this.process.kill("SIGCONT");
304
+ signalGroup(this.process, "SIGCONT");
184
305
  // Restart timeout timer with remaining time (use full timeout for simplicity)
185
306
  this.timeoutTimer = setTimeout(() => {
186
307
  log.warn(this.tag, `Timeout reached (${this.config.maxTimeout}ms), cancelling`);
@@ -210,31 +331,22 @@ export class Worker {
210
331
  this.state = "cancelling";
211
332
  log.info(this.tag, `Cancelling work on ${this.cardId}`);
212
333
  if (this.process && !this.process.killed) {
213
- // Resume first in case the process is suspended
214
- this.process.kill("SIGCONT");
215
- // Step 1: SIGINT (let Claude save state gracefully)
216
- this.process.kill("SIGINT");
217
- log.debug(this.tag, "Sent SIGINT");
218
- const sigintDead = await this.waitForExit(CANCEL_SIGINT_TIMEOUT);
219
- if (sigintDead)
220
- return;
221
- // Step 2: SIGTERM
222
- this.process.kill("SIGTERM");
223
- log.debug(this.tag, "Sent SIGTERM");
224
- const sigtermDead = await this.waitForExit(CANCEL_SIGTERM_TIMEOUT);
225
- if (sigtermDead)
226
- return;
227
- // Step 3: SIGKILL
228
- this.process.kill("SIGKILL");
229
- log.warn(this.tag, "Sent SIGKILL");
334
+ await terminateGroup(this.process, {
335
+ sigintTimeoutMs: CANCEL_SIGINT_TIMEOUT,
336
+ sigtermTimeoutMs: CANCEL_SIGTERM_TIMEOUT,
337
+ });
230
338
  }
231
- // End agent session as paused
339
+ // End agent session as paused (retry on API jitter).
232
340
  if (this.cardId) {
233
341
  try {
234
- await this.client.endAgentSession(this.cardId, { status: "paused" });
342
+ const stats = this.lastSessionStats ?? this.progressTracker?.stats;
343
+ await this.client.endAgentSession(this.cardId, {
344
+ status: "paused",
345
+ ...buildTokenPayload(stats),
346
+ });
235
347
  }
236
- catch {
237
- // best-effort
348
+ catch (err) {
349
+ log.warn(this.tag, `endAgentSession after cancel failed: ${err instanceof Error ? err.message : err}`);
238
350
  }
239
351
  }
240
352
  }
@@ -256,7 +368,13 @@ export class Worker {
256
368
  prompt,
257
369
  ];
258
370
  log.info(this.tag, `Spawning: claude ${args.slice(0, 4).join(" ")} ...`);
259
- this.process = spawn("claude", args, {
371
+ const runLog = openRunLog(this.tag, this.runId, card.short_id);
372
+ if (runLog) {
373
+ log.info(this.tag, `Run log: ${runLog.path}`);
374
+ runLog.stream.write(`# run=${this.runId} card=#${card.short_id} started=${new Date().toISOString()}\n` +
375
+ `# args: ${args.slice(0, -2).join(" ")} -- <prompt:${prompt.length} chars>\n\n`);
376
+ }
377
+ this.process = spawnInGroup("claude", args, {
260
378
  cwd: this.worktreePath,
261
379
  stdio: ["ignore", "pipe", "pipe"],
262
380
  });
@@ -271,13 +389,20 @@ export class Worker {
271
389
  // Attach stdout to parser
272
390
  if (this.process.stdout) {
273
391
  parser.attach(this.process.stdout);
392
+ if (runLog) {
393
+ this.process.stdout.on("data", (chunk) => {
394
+ runLog.stream.write(chunk);
395
+ });
396
+ }
274
397
  }
275
398
  parser.on("parse_error", (msg) => {
276
399
  log.debug(this.tag, `Stream parse error (non-fatal): ${msg}`);
400
+ runLog?.stream.write(`\n[parse_error] ${msg}\n`);
277
401
  });
278
402
  let stderr = "";
279
403
  this.process.stderr?.on("data", (data) => {
280
404
  stderr += data.toString();
405
+ runLog?.stream.write(`[stderr] ${data.toString()}`);
281
406
  });
282
407
  this.process.on("error", (err) => {
283
408
  reject(new Error(`Failed to spawn claude: ${err.message}`));
@@ -288,6 +413,14 @@ export class Worker {
288
413
  this.progressTracker?.flushFinal();
289
414
  this.progressTracker?.stop();
290
415
  this.progressTracker = null;
416
+ if (runLog) {
417
+ const stats = this.lastSessionStats;
418
+ runLog.stream.write(`\n# exit code=${code} aborted=${this.aborted} ` +
419
+ `toolCalls=${stats?.toolCalls ?? 0} filesEdited=${stats?.filesEdited ?? 0} ` +
420
+ `cost=$${stats?.cost?.totalCostUsd.toFixed(4) ?? "0"} ` +
421
+ `ended=${new Date().toISOString()}\n`);
422
+ runLog.stream.end();
423
+ }
291
424
  if (this.aborted) {
292
425
  resolve(); // Cancellation is not an error
293
426
  }
@@ -300,26 +433,12 @@ export class Worker {
300
433
  });
301
434
  });
302
435
  }
303
- waitForExit(timeout) {
304
- return new Promise((resolve) => {
305
- if (!this.process || this.process.killed) {
306
- resolve(true);
307
- return;
308
- }
309
- const timer = setTimeout(() => {
310
- resolve(false);
311
- }, timeout);
312
- this.process.once("close", () => {
313
- clearTimeout(timer);
314
- resolve(true);
315
- });
316
- });
317
- }
318
436
  cleanup() {
319
437
  if (this.progressTracker) {
320
438
  this.progressTracker.stop();
321
439
  this.progressTracker = null;
322
440
  }
441
+ this.stopHeartbeat();
323
442
  this.lastSessionStats = undefined;
324
443
  if (this.timeoutTimer) {
325
444
  clearTimeout(this.timeoutTimer);
@@ -339,5 +458,7 @@ export class Worker {
339
458
  this.branchName = null;
340
459
  this.worktreePath = null;
341
460
  this.startedAt = null;
461
+ this.runId = null;
462
+ this.sessionId = null;
342
463
  }
343
464
  }
@@ -0,0 +1,39 @@
1
+ import type { StateStore } from "./state-store.js";
2
+ export interface GcResult {
3
+ checked: number;
4
+ removed: string[];
5
+ skipped: string[];
6
+ errors: Array<{
7
+ path: string;
8
+ error: string;
9
+ }>;
10
+ }
11
+ export interface GcOptions {
12
+ /** Directories younger than this are kept (a worker may be about to use them). */
13
+ minAgeMs?: number;
14
+ /** Optional sync clock, for deterministic tests. */
15
+ now?: () => number;
16
+ }
17
+ /**
18
+ * One-shot garbage collection for `.harmony-worktrees/*`.
19
+ *
20
+ * A directory is removed when BOTH:
21
+ * - no active run in the state store has it as its `worktreePath`, AND
22
+ * - it was last modified more than `minAgeMs` ago (default 1h).
23
+ *
24
+ * The age check protects brand-new worktrees that a worker just created
25
+ * but hasn't yet recorded the path for in the state store.
26
+ *
27
+ * Returns a summary; callers decide whether to log at info or warn.
28
+ */
29
+ export declare function runWorktreeGc(basePath: string, store: StateStore, opts?: GcOptions): GcResult;
30
+ export declare class WorktreeGc {
31
+ private basePath;
32
+ private store;
33
+ private intervalMs;
34
+ private timer;
35
+ constructor(basePath: string, store: StateStore, intervalMs: number);
36
+ start(): void;
37
+ stop(): void;
38
+ private tick;
39
+ }
@@ -0,0 +1,139 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { log } from "./log.js";
5
+ import { cleanupWorktree } from "./worktree.js";
6
+ const TAG = "worktree-gc";
7
+ /**
8
+ * One-shot garbage collection for `.harmony-worktrees/*`.
9
+ *
10
+ * A directory is removed when BOTH:
11
+ * - no active run in the state store has it as its `worktreePath`, AND
12
+ * - it was last modified more than `minAgeMs` ago (default 1h).
13
+ *
14
+ * The age check protects brand-new worktrees that a worker just created
15
+ * but hasn't yet recorded the path for in the state store.
16
+ *
17
+ * Returns a summary; callers decide whether to log at info or warn.
18
+ */
19
+ export function runWorktreeGc(basePath, store, opts = {}) {
20
+ const minAgeMs = opts.minAgeMs ?? 60 * 60 * 1000;
21
+ const now = (opts.now ?? Date.now)();
22
+ const result = { checked: 0, removed: [], skipped: [], errors: [] };
23
+ const repoRoot = getRepoRoot();
24
+ if (!repoRoot) {
25
+ result.errors.push({ path: "<repo-root>", error: "not a git repo" });
26
+ return result;
27
+ }
28
+ const baseAbs = resolve(repoRoot, basePath);
29
+ let entries;
30
+ try {
31
+ entries = readdirSync(baseAbs);
32
+ }
33
+ catch {
34
+ // Directory doesn't exist yet — nothing to GC, not an error.
35
+ return result;
36
+ }
37
+ const activePaths = new Set(store
38
+ .getActiveRuns()
39
+ .map((r) => r.worktreePath)
40
+ .filter((p) => !!p));
41
+ for (const entry of entries) {
42
+ const full = resolve(baseAbs, entry);
43
+ result.checked++;
44
+ let mtimeMs;
45
+ try {
46
+ const stat = statSync(full);
47
+ if (!stat.isDirectory()) {
48
+ result.skipped.push(full);
49
+ continue;
50
+ }
51
+ mtimeMs = stat.mtimeMs;
52
+ }
53
+ catch (err) {
54
+ result.errors.push({
55
+ path: full,
56
+ error: err instanceof Error ? err.message : String(err),
57
+ });
58
+ continue;
59
+ }
60
+ if (activePaths.has(full)) {
61
+ result.skipped.push(full);
62
+ continue;
63
+ }
64
+ if (now - mtimeMs < minAgeMs) {
65
+ result.skipped.push(full);
66
+ continue;
67
+ }
68
+ try {
69
+ cleanupWorktree(full);
70
+ result.removed.push(full);
71
+ }
72
+ catch (err) {
73
+ result.errors.push({
74
+ path: full,
75
+ error: err instanceof Error ? err.message : String(err),
76
+ });
77
+ }
78
+ }
79
+ // Prune any stale metadata leftover from deleted worktrees.
80
+ try {
81
+ execFileSync("git", ["worktree", "prune"], {
82
+ cwd: repoRoot,
83
+ stdio: "pipe",
84
+ });
85
+ }
86
+ catch {
87
+ // non-fatal
88
+ }
89
+ if (result.removed.length > 0) {
90
+ log.info(TAG, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
91
+ }
92
+ if (result.errors.length > 0) {
93
+ log.warn(TAG, `GC had ${result.errors.length} error(s): ${result.errors
94
+ .map((e) => `${e.path}: ${e.error}`)
95
+ .join("; ")}`);
96
+ }
97
+ return result;
98
+ }
99
+ export class WorktreeGc {
100
+ basePath;
101
+ store;
102
+ intervalMs;
103
+ timer = null;
104
+ constructor(basePath, store, intervalMs) {
105
+ this.basePath = basePath;
106
+ this.store = store;
107
+ this.intervalMs = intervalMs;
108
+ }
109
+ start() {
110
+ log.info(TAG, `worktree GC every ${this.intervalMs / 1000}s`);
111
+ // Run once at startup, then on interval.
112
+ this.tick();
113
+ this.timer = setInterval(() => this.tick(), this.intervalMs);
114
+ }
115
+ stop() {
116
+ if (this.timer) {
117
+ clearInterval(this.timer);
118
+ this.timer = null;
119
+ }
120
+ }
121
+ tick() {
122
+ try {
123
+ runWorktreeGc(this.basePath, this.store);
124
+ }
125
+ catch (err) {
126
+ log.warn(TAG, `GC tick failed: ${err instanceof Error ? err.message : err}`);
127
+ }
128
+ }
129
+ }
130
+ function getRepoRoot() {
131
+ try {
132
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
133
+ encoding: "utf-8",
134
+ }).trim();
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/agent",
3
- "version": "1.0.9",
3
+ "version": "1.1.1",
4
4
  "description": "Push-based agent daemon for Harmony — watches board assignments and spawns Claude CLI workers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@supabase/supabase-js": "2.95.3",
48
- "@gethmy/mcp": "^2.0.0"
48
+ "@gethmy/mcp": "^2.4.3"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@harmony/shared": "workspace:*",