@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.
- 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 +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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
136
|
-
|
|
207
|
+
await runTransition(this.client, card, {
|
|
208
|
+
endSession: {
|
|
209
|
+
status: "paused",
|
|
210
|
+
...buildTokenPayload(this.lastSessionStats),
|
|
211
|
+
},
|
|
137
212
|
});
|
|
138
213
|
}
|
|
139
|
-
catch {
|
|
140
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
48
|
+
"@gethmy/mcp": "^2.4.3"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@harmony/shared": "workspace:*",
|