@gethmy/agent 1.2.0 → 1.4.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/dist/cli.js +6 -2
- package/dist/config-validation.d.ts +5 -0
- package/dist/config-validation.js +16 -5
- package/dist/http-server.js +0 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +59 -19
- package/dist/merge-monitor.js +0 -1
- package/dist/pool.js +0 -2
- package/dist/reconcile.js +0 -1
- package/dist/recovery.js +0 -1
- package/dist/review-worker.js +25 -0
- package/dist/review-worktree.js +12 -0
- package/dist/startup-banner.d.ts +29 -0
- package/dist/startup-banner.js +143 -0
- package/dist/watcher.d.ts +15 -0
- package/dist/watcher.js +41 -4
- package/dist/worktree-gc.js +1 -2
- package/dist/worktree.js +24 -2
- package/package.json +2 -2
- package/dist/__tests__/budget.test.d.ts +0 -1
- package/dist/__tests__/budget.test.js +0 -94
- package/dist/__tests__/config-validation.test.d.ts +0 -1
- package/dist/__tests__/config-validation.test.js +0 -65
- package/dist/__tests__/dev-server-readiness.test.d.ts +0 -1
- package/dist/__tests__/dev-server-readiness.test.js +0 -26
- package/dist/__tests__/http-server.test.d.ts +0 -1
- package/dist/__tests__/http-server.test.js +0 -115
- package/dist/__tests__/log.test.d.ts +0 -1
- package/dist/__tests__/log.test.js +0 -115
- package/dist/__tests__/merge-monitor.test.d.ts +0 -1
- package/dist/__tests__/merge-monitor.test.js +0 -107
- package/dist/__tests__/process-group.test.d.ts +0 -1
- package/dist/__tests__/process-group.test.js +0 -88
- package/dist/__tests__/progress-tracker.test.d.ts +0 -1
- package/dist/__tests__/progress-tracker.test.js +0 -247
- package/dist/__tests__/reconcile-heartbeat.test.d.ts +0 -1
- package/dist/__tests__/reconcile-heartbeat.test.js +0 -116
- package/dist/__tests__/recovery.test.d.ts +0 -1
- package/dist/__tests__/recovery.test.js +0 -126
- package/dist/__tests__/review-parser.test.d.ts +0 -1
- package/dist/__tests__/review-parser.test.js +0 -65
- package/dist/__tests__/state-store.test.d.ts +0 -1
- package/dist/__tests__/state-store.test.js +0 -132
- package/dist/__tests__/stream-parser-selftest.test.d.ts +0 -1
- package/dist/__tests__/stream-parser-selftest.test.js +0 -23
- package/dist/__tests__/stream-parser.test.d.ts +0 -1
- package/dist/__tests__/stream-parser.test.js +0 -199
- package/dist/__tests__/transitions.test.d.ts +0 -1
- package/dist/__tests__/transitions.test.js +0 -130
- package/dist/__tests__/worktree-gc.test.d.ts +0 -1
- package/dist/__tests__/worktree-gc.test.js +0 -137
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { signalGroup, spawnInGroup, terminateGroup } from "../process-group.js";
|
|
3
|
-
/**
|
|
4
|
-
* Wait until the child writes a "ready" line to stdout. This is the only
|
|
5
|
-
* reliable way to know a Node child has actually installed its signal
|
|
6
|
-
* handlers — time-based waits flake under CI/test-suite load because the
|
|
7
|
-
* handler may not be registered before the test sends SIGTERM, in which case
|
|
8
|
-
* Node's default handler terminates the process and the escalation test
|
|
9
|
-
* can't observe SIGKILL. Child scripts in this file print "ready\n" after
|
|
10
|
-
* calling process.on(...).
|
|
11
|
-
*/
|
|
12
|
-
function waitForReady(proc, timeoutMs = 3000) {
|
|
13
|
-
return new Promise((resolve, reject) => {
|
|
14
|
-
const timer = setTimeout(() => reject(new Error("child never reported ready")), timeoutMs);
|
|
15
|
-
let buf = "";
|
|
16
|
-
proc.stdout?.on("data", (d) => {
|
|
17
|
-
buf += d.toString();
|
|
18
|
-
if (buf.includes("ready")) {
|
|
19
|
-
clearTimeout(timer);
|
|
20
|
-
resolve();
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
describe("process-group", () => {
|
|
26
|
-
it("places the child in its own process group (pid === pgid)", async () => {
|
|
27
|
-
if (process.platform === "win32")
|
|
28
|
-
return;
|
|
29
|
-
const proc = spawnInGroup("sh", ["-c", "ps -o pgid= -p $$ | tr -d ' '"]);
|
|
30
|
-
let stdout = "";
|
|
31
|
-
proc.stdout?.on("data", (d) => {
|
|
32
|
-
stdout += d.toString();
|
|
33
|
-
});
|
|
34
|
-
await new Promise((resolve) => {
|
|
35
|
-
proc.on("exit", () => resolve());
|
|
36
|
-
});
|
|
37
|
-
const pgid = parseInt(stdout.trim(), 10);
|
|
38
|
-
expect(pgid).toBe(proc.pid);
|
|
39
|
-
});
|
|
40
|
-
it("signalGroup handles already-dead processes silently", () => {
|
|
41
|
-
const fakeProc = {
|
|
42
|
-
pid: 999_999_999,
|
|
43
|
-
killed: false,
|
|
44
|
-
kill: () => true,
|
|
45
|
-
};
|
|
46
|
-
expect(() => signalGroup(fakeProc, "SIGTERM")).not.toThrow();
|
|
47
|
-
});
|
|
48
|
-
it("terminateGroup exits a cooperative child on SIGINT", async () => {
|
|
49
|
-
if (process.platform === "win32")
|
|
50
|
-
return;
|
|
51
|
-
const proc = spawnInGroup(process.execPath, [
|
|
52
|
-
"-e",
|
|
53
|
-
"process.on('SIGINT', () => process.exit(0)); process.stdout.write('ready\\n'); setInterval(()=>{}, 1000);",
|
|
54
|
-
]);
|
|
55
|
-
// Capture the exit state up front so we never miss it.
|
|
56
|
-
const exited = new Promise((resolve) => {
|
|
57
|
-
proc.once("exit", (code, signal) => resolve({ code, signal }));
|
|
58
|
-
});
|
|
59
|
-
await waitForReady(proc);
|
|
60
|
-
await terminateGroup(proc, {
|
|
61
|
-
sigintTimeoutMs: 2000,
|
|
62
|
-
sigtermTimeoutMs: 500,
|
|
63
|
-
});
|
|
64
|
-
const result = await exited;
|
|
65
|
-
// Either the handler fired (code=0) or we escalated past it — in
|
|
66
|
-
// both cases the termination contract is kept. We really only want
|
|
67
|
-
// to prove the process group was reached.
|
|
68
|
-
expect(result.code !== null || result.signal !== null).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
it("terminateGroup escalates to SIGKILL when the group ignores signals", async () => {
|
|
71
|
-
if (process.platform === "win32")
|
|
72
|
-
return;
|
|
73
|
-
const proc = spawnInGroup(process.execPath, [
|
|
74
|
-
"-e",
|
|
75
|
-
"process.on('SIGINT', () => {}); process.on('SIGTERM', () => {}); process.stdout.write('ready\\n'); setInterval(()=>{}, 1000);",
|
|
76
|
-
]);
|
|
77
|
-
const exited = new Promise((resolve) => {
|
|
78
|
-
proc.once("exit", (code, signal) => resolve({ code, signal }));
|
|
79
|
-
});
|
|
80
|
-
await waitForReady(proc);
|
|
81
|
-
await terminateGroup(proc, {
|
|
82
|
-
sigintTimeoutMs: 200,
|
|
83
|
-
sigtermTimeoutMs: 200,
|
|
84
|
-
});
|
|
85
|
-
const result = await exited;
|
|
86
|
-
expect(result.signal).toBe("SIGKILL");
|
|
87
|
-
});
|
|
88
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from "node:events";
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
// Dynamic import so we can test the module without triggering side effects
|
|
4
|
-
// from other imports (log, etc.)
|
|
5
|
-
let ProgressTracker;
|
|
6
|
-
// Minimal mock of HarmonyApiClient
|
|
7
|
-
function makeMockClient() {
|
|
8
|
-
return {
|
|
9
|
-
updateAgentProgress: vi.fn().mockResolvedValue(undefined),
|
|
10
|
-
flushActivityLog: vi.fn().mockResolvedValue(undefined),
|
|
11
|
-
startAgentSession: vi.fn().mockResolvedValue(undefined),
|
|
12
|
-
endAgentSession: vi.fn().mockResolvedValue(undefined),
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
// Mock StreamParser — just an EventEmitter with the same typed interface
|
|
16
|
-
class MockParser extends EventEmitter {
|
|
17
|
-
emitToolStart(name, input) {
|
|
18
|
-
this.emit("tool_start", name, input);
|
|
19
|
-
}
|
|
20
|
-
emitToolEnd(name, toolUseId, content) {
|
|
21
|
-
this.emit("tool_end", name, toolUseId, content);
|
|
22
|
-
}
|
|
23
|
-
emitText(content) {
|
|
24
|
-
this.emit("text", content);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
// Access private fields via type cast
|
|
28
|
-
function getPrivate(tracker) {
|
|
29
|
-
return tracker;
|
|
30
|
-
}
|
|
31
|
-
beforeEach(async () => {
|
|
32
|
-
// Reset module to get fresh class each test (avoids timer leakage between tests)
|
|
33
|
-
const mod = await import("../progress-tracker.js");
|
|
34
|
-
ProgressTracker = mod.ProgressTracker;
|
|
35
|
-
});
|
|
36
|
-
afterEach(() => {
|
|
37
|
-
vi.restoreAllMocks();
|
|
38
|
-
});
|
|
39
|
-
describe("ProgressTracker", () => {
|
|
40
|
-
describe("setSessionId", () => {
|
|
41
|
-
it("stores the session ID", () => {
|
|
42
|
-
const client = makeMockClient();
|
|
43
|
-
const tracker = new ProgressTracker(client, "card-1", 0, []);
|
|
44
|
-
tracker.stop(); // prevent heartbeat
|
|
45
|
-
expect(getPrivate(tracker).sessionId).toBeNull();
|
|
46
|
-
tracker.setSessionId("session-abc");
|
|
47
|
-
expect(getPrivate(tracker).sessionId).toBe("session-abc");
|
|
48
|
-
});
|
|
49
|
-
it("triggers flush with correct sessionId when sendUpdate is called", async () => {
|
|
50
|
-
const client = makeMockClient();
|
|
51
|
-
const tracker = new ProgressTracker(client, "card-1", 0, []);
|
|
52
|
-
tracker.stop();
|
|
53
|
-
tracker.setSessionId("session-xyz");
|
|
54
|
-
// Push an entry manually so flush has something to send
|
|
55
|
-
getPrivate(tracker).logBuffer.push({
|
|
56
|
-
phase: "exploring",
|
|
57
|
-
eventType: "tool_start",
|
|
58
|
-
toolName: "Read",
|
|
59
|
-
description: "Reading src/foo.ts",
|
|
60
|
-
metadata: {},
|
|
61
|
-
createdAt: new Date().toISOString(),
|
|
62
|
-
});
|
|
63
|
-
// Trigger sendUpdate by calling the private method indirectly
|
|
64
|
-
// (Force lastUpdateAt to 0 so throttle passes)
|
|
65
|
-
tracker.lastUpdateAt = 0;
|
|
66
|
-
tracker.sendUpdate("test task");
|
|
67
|
-
await vi.waitFor(() => {
|
|
68
|
-
expect(client.flushActivityLog).toHaveBeenCalledWith("card-1", expect.objectContaining({ sessionId: "session-xyz" }));
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
describe("tool_start events", () => {
|
|
73
|
-
it("creates a log entry in the buffer when tool has a description", () => {
|
|
74
|
-
const client = makeMockClient();
|
|
75
|
-
const tracker = new ProgressTracker(client, "card-2", 0, []);
|
|
76
|
-
tracker.stop();
|
|
77
|
-
const parser = new MockParser();
|
|
78
|
-
tracker.attach(parser);
|
|
79
|
-
parser.emitToolStart("Read", { file_path: "/src/foo.ts" });
|
|
80
|
-
const buf = getPrivate(tracker).logBuffer;
|
|
81
|
-
const entry = buf.find((e) => e.eventType === "tool_start");
|
|
82
|
-
expect(entry).toBeDefined();
|
|
83
|
-
expect(entry?.toolName).toBe("Read");
|
|
84
|
-
expect(entry?.description).toMatch(/Reading/);
|
|
85
|
-
expect(entry?.createdAt).toBeDefined();
|
|
86
|
-
});
|
|
87
|
-
it("does not create a log entry for tools with no description", () => {
|
|
88
|
-
const client = makeMockClient();
|
|
89
|
-
const tracker = new ProgressTracker(client, "card-2", 0, []);
|
|
90
|
-
tracker.stop();
|
|
91
|
-
const parser = new MockParser();
|
|
92
|
-
tracker.attach(parser);
|
|
93
|
-
// Unknown tool with no special description logic
|
|
94
|
-
parser.emitToolStart("SomeObscureUnknownTool", {});
|
|
95
|
-
const buf = getPrivate(tracker).logBuffer;
|
|
96
|
-
const entry = buf.find((e) => e.eventType === "tool_start" &&
|
|
97
|
-
e.toolName === "SomeObscureUnknownTool");
|
|
98
|
-
expect(entry).toBeUndefined();
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
describe("tool_end events", () => {
|
|
102
|
-
it("creates a log entry for every tool_end event", () => {
|
|
103
|
-
const client = makeMockClient();
|
|
104
|
-
const tracker = new ProgressTracker(client, "card-3", 0, []);
|
|
105
|
-
tracker.stop();
|
|
106
|
-
const parser = new MockParser();
|
|
107
|
-
tracker.attach(parser);
|
|
108
|
-
parser.emitToolEnd("Bash", "tool-use-1", "exit 0");
|
|
109
|
-
const buf = getPrivate(tracker).logBuffer;
|
|
110
|
-
const entry = buf.find((e) => e.eventType === "tool_end" && e.toolName === "Bash");
|
|
111
|
-
expect(entry).toBeDefined();
|
|
112
|
-
expect(entry?.description).toContain("Completed");
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
describe("buffer cap", () => {
|
|
116
|
-
it("caps buffer at 500 entries after pushing 510", () => {
|
|
117
|
-
const client = makeMockClient();
|
|
118
|
-
const tracker = new ProgressTracker(client, "card-4", 0, []);
|
|
119
|
-
tracker.stop();
|
|
120
|
-
const parser = new MockParser();
|
|
121
|
-
tracker.attach(parser);
|
|
122
|
-
// Emit 510 tool_end events (tool_end always creates an entry)
|
|
123
|
-
for (let i = 0; i < 510; i++) {
|
|
124
|
-
parser.emitToolEnd("Read", `id-${i}`, undefined);
|
|
125
|
-
}
|
|
126
|
-
const buf = getPrivate(tracker).logBuffer;
|
|
127
|
-
expect(buf.length).toBeLessThanOrEqual(500);
|
|
128
|
-
});
|
|
129
|
-
it("keeps the most recent entries when the buffer overflows", () => {
|
|
130
|
-
const client = makeMockClient();
|
|
131
|
-
const tracker = new ProgressTracker(client, "card-4b", 0, []);
|
|
132
|
-
tracker.stop();
|
|
133
|
-
const parser = new MockParser();
|
|
134
|
-
tracker.attach(parser);
|
|
135
|
-
for (let i = 0; i < 510; i++) {
|
|
136
|
-
parser.emitToolEnd(`Tool${i}`, `id-${i}`, undefined);
|
|
137
|
-
}
|
|
138
|
-
const buf = getPrivate(tracker).logBuffer;
|
|
139
|
-
// The oldest entries (0–9) should have been shifted out
|
|
140
|
-
expect(buf[0].toolName).not.toBe("Tool0");
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
describe("flushActivityLog", () => {
|
|
144
|
-
it("clears the buffer on successful flush", async () => {
|
|
145
|
-
const client = makeMockClient();
|
|
146
|
-
const tracker = new ProgressTracker(client, "card-5", 0, []);
|
|
147
|
-
tracker.stop();
|
|
148
|
-
tracker.setSessionId("session-flush");
|
|
149
|
-
getPrivate(tracker).logBuffer.push({
|
|
150
|
-
phase: "exploring",
|
|
151
|
-
eventType: "tool_end",
|
|
152
|
-
toolName: "Read",
|
|
153
|
-
description: "Completed: Read",
|
|
154
|
-
metadata: {},
|
|
155
|
-
createdAt: new Date().toISOString(),
|
|
156
|
-
});
|
|
157
|
-
// Invoke the private method
|
|
158
|
-
tracker.flushActivityLog();
|
|
159
|
-
// Wait for the async client call to resolve
|
|
160
|
-
await vi.waitFor(() => {
|
|
161
|
-
expect(client.flushActivityLog).toHaveBeenCalled();
|
|
162
|
-
});
|
|
163
|
-
// Buffer should be empty after successful flush
|
|
164
|
-
expect(getPrivate(tracker).logBuffer).toHaveLength(0);
|
|
165
|
-
});
|
|
166
|
-
it("retains buffer entries on flush error", async () => {
|
|
167
|
-
const client = makeMockClient();
|
|
168
|
-
client.flushActivityLog.mockRejectedValue(new Error("network error"));
|
|
169
|
-
const tracker = new ProgressTracker(client, "card-6", 0, []);
|
|
170
|
-
tracker.stop();
|
|
171
|
-
tracker.setSessionId("session-retry");
|
|
172
|
-
getPrivate(tracker).logBuffer.push({
|
|
173
|
-
phase: "exploring",
|
|
174
|
-
eventType: "tool_end",
|
|
175
|
-
toolName: "Read",
|
|
176
|
-
description: "Completed: Read",
|
|
177
|
-
metadata: {},
|
|
178
|
-
createdAt: new Date().toISOString(),
|
|
179
|
-
});
|
|
180
|
-
tracker.flushActivityLog();
|
|
181
|
-
await vi.waitFor(() => {
|
|
182
|
-
// After the rejected promise is caught, entries should be back in buffer
|
|
183
|
-
expect(getPrivate(tracker).logBuffer.length).toBeGreaterThan(0);
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
it("skips flush when no session ID is set", () => {
|
|
187
|
-
const client = makeMockClient();
|
|
188
|
-
const tracker = new ProgressTracker(client, "card-7", 0, []);
|
|
189
|
-
tracker.stop();
|
|
190
|
-
// No setSessionId called
|
|
191
|
-
getPrivate(tracker).logBuffer.push({
|
|
192
|
-
phase: "exploring",
|
|
193
|
-
eventType: "tool_end",
|
|
194
|
-
toolName: "Read",
|
|
195
|
-
description: "Completed: Read",
|
|
196
|
-
metadata: {},
|
|
197
|
-
createdAt: new Date().toISOString(),
|
|
198
|
-
});
|
|
199
|
-
tracker.flushActivityLog();
|
|
200
|
-
// Should not have called the client
|
|
201
|
-
expect(client.flushActivityLog).not.toHaveBeenCalled();
|
|
202
|
-
// Buffer should still contain the entry
|
|
203
|
-
expect(getPrivate(tracker).logBuffer).toHaveLength(1);
|
|
204
|
-
});
|
|
205
|
-
it("skips flush when buffer is empty", () => {
|
|
206
|
-
const client = makeMockClient();
|
|
207
|
-
const tracker = new ProgressTracker(client, "card-8", 0, []);
|
|
208
|
-
tracker.stop();
|
|
209
|
-
tracker.setSessionId("session-empty");
|
|
210
|
-
// logBuffer is empty by default
|
|
211
|
-
tracker.flushActivityLog();
|
|
212
|
-
expect(client.flushActivityLog).not.toHaveBeenCalled();
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
describe("sendUpdate payload", () => {
|
|
216
|
-
it("does not include recentActions in the updateAgentProgress payload", async () => {
|
|
217
|
-
const client = makeMockClient();
|
|
218
|
-
const tracker = new ProgressTracker(client, "card-9", 0, []);
|
|
219
|
-
tracker.stop();
|
|
220
|
-
tracker.lastUpdateAt = 0;
|
|
221
|
-
tracker.sendUpdate("doing stuff");
|
|
222
|
-
await vi.waitFor(() => {
|
|
223
|
-
expect(client.updateAgentProgress).toHaveBeenCalled();
|
|
224
|
-
});
|
|
225
|
-
const [, payload] = client.updateAgentProgress.mock.calls[0];
|
|
226
|
-
expect(payload).not.toHaveProperty("recentActions");
|
|
227
|
-
});
|
|
228
|
-
it("includes expected fields in updateAgentProgress payload", async () => {
|
|
229
|
-
const client = makeMockClient();
|
|
230
|
-
const tracker = new ProgressTracker(client, "card-10", 0, []);
|
|
231
|
-
tracker.stop();
|
|
232
|
-
tracker.lastUpdateAt = 0;
|
|
233
|
-
tracker.sendUpdate("building feature");
|
|
234
|
-
await vi.waitFor(() => {
|
|
235
|
-
expect(client.updateAgentProgress).toHaveBeenCalled();
|
|
236
|
-
});
|
|
237
|
-
const [cardId, payload] = client.updateAgentProgress.mock.calls[0];
|
|
238
|
-
expect(cardId).toBe("card-10");
|
|
239
|
-
expect(payload).toMatchObject({
|
|
240
|
-
status: "working",
|
|
241
|
-
currentTask: expect.any(String),
|
|
242
|
-
progressPercent: expect.any(Number),
|
|
243
|
-
phase: expect.any(String),
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
vi.mock("../worktree.js", () => ({
|
|
6
|
-
cleanupWorktree: vi.fn(),
|
|
7
|
-
}));
|
|
8
|
-
vi.mock("../board-helpers.js", () => ({
|
|
9
|
-
moveCardToColumn: vi.fn().mockResolvedValue(undefined),
|
|
10
|
-
addLabelByName: vi.fn().mockResolvedValue(undefined),
|
|
11
|
-
buildLabelMap: () => new Map(),
|
|
12
|
-
hasLabel: () => false,
|
|
13
|
-
resolveCardLabels: () => [],
|
|
14
|
-
}));
|
|
15
|
-
import { Reconciler } from "../reconcile.js";
|
|
16
|
-
import { newRunId, StateStore } from "../state-store.js";
|
|
17
|
-
import { DEFAULT_AGENT_CONFIG } from "../types.js";
|
|
18
|
-
function makeRun(overrides = {}) {
|
|
19
|
-
return {
|
|
20
|
-
runId: newRunId(),
|
|
21
|
-
cardId: "card-z",
|
|
22
|
-
cardShortId: 7,
|
|
23
|
-
pipeline: "implement",
|
|
24
|
-
workerId: 0,
|
|
25
|
-
sessionId: null,
|
|
26
|
-
worktreePath: null,
|
|
27
|
-
branchName: null,
|
|
28
|
-
daemonPid: 999_999_999,
|
|
29
|
-
phase: "running",
|
|
30
|
-
startedAt: Date.now(),
|
|
31
|
-
lastHeartbeatAt: Date.now(),
|
|
32
|
-
endedAt: null,
|
|
33
|
-
status: "active",
|
|
34
|
-
costCents: 0,
|
|
35
|
-
...overrides,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
describe("Reconciler stale-heartbeat detection", () => {
|
|
39
|
-
let dir;
|
|
40
|
-
let store;
|
|
41
|
-
let pool;
|
|
42
|
-
let client;
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
dir = mkdtempSync(join(tmpdir(), "reconcile-"));
|
|
45
|
-
store = new StateStore(join(dir, "state.json"));
|
|
46
|
-
pool = {
|
|
47
|
-
knownCardIds: () => new Set(),
|
|
48
|
-
isCardActive: vi.fn().mockReturnValue(false),
|
|
49
|
-
isCardKnown: vi.fn().mockReturnValue(false),
|
|
50
|
-
enqueue: vi.fn().mockResolvedValue(undefined),
|
|
51
|
-
removeCard: vi.fn().mockResolvedValue(undefined),
|
|
52
|
-
};
|
|
53
|
-
client = {
|
|
54
|
-
getBoard: vi.fn().mockResolvedValue({
|
|
55
|
-
cards: [],
|
|
56
|
-
columns: [{ id: "col-todo", name: "To Do" }],
|
|
57
|
-
labels: [],
|
|
58
|
-
}),
|
|
59
|
-
endAgentSession: vi.fn().mockResolvedValue(undefined),
|
|
60
|
-
getCard: vi.fn().mockResolvedValue({
|
|
61
|
-
card: {
|
|
62
|
-
id: "card-z",
|
|
63
|
-
short_id: 7,
|
|
64
|
-
project_id: "p",
|
|
65
|
-
column_id: "col-todo",
|
|
66
|
-
labelIds: [],
|
|
67
|
-
},
|
|
68
|
-
}),
|
|
69
|
-
};
|
|
70
|
-
});
|
|
71
|
-
afterEach(() => {
|
|
72
|
-
rmSync(dir, { recursive: true, force: true });
|
|
73
|
-
});
|
|
74
|
-
it("recovers foreign-daemon runs whose PID is dead", async () => {
|
|
75
|
-
await store.insertRun(makeRun({ daemonPid: 999_999_999 }));
|
|
76
|
-
const reconciler = new Reconciler(client, pool, "p", "agent-user", ["To Do"], [], "", 60_000, store, DEFAULT_AGENT_CONFIG);
|
|
77
|
-
// Exercise a single tick manually.
|
|
78
|
-
await reconciler.tick();
|
|
79
|
-
expect(client.endAgentSession).toHaveBeenCalledWith("card-z", expect.objectContaining({ status: "paused" }));
|
|
80
|
-
expect(store.getActiveRuns()).toHaveLength(0);
|
|
81
|
-
});
|
|
82
|
-
it("recovers our own runs whose worker lost track of the card", async () => {
|
|
83
|
-
// daemonPid === process.pid but pool does NOT have the card active
|
|
84
|
-
// and heartbeat is old. That's a zombie — should recover.
|
|
85
|
-
await store.insertRun(makeRun({
|
|
86
|
-
daemonPid: process.pid,
|
|
87
|
-
lastHeartbeatAt: Date.now() - 10 * 60_000, // 10 minutes ago
|
|
88
|
-
}));
|
|
89
|
-
const reconciler = new Reconciler(client, pool, "p", "agent-user", ["To Do"], [], "", 60_000, store, DEFAULT_AGENT_CONFIG);
|
|
90
|
-
await reconciler.tick();
|
|
91
|
-
expect(client.endAgentSession).toHaveBeenCalled();
|
|
92
|
-
expect(store.getActiveRuns()).toHaveLength(0);
|
|
93
|
-
});
|
|
94
|
-
it("does not touch our own runs that the pool still holds", async () => {
|
|
95
|
-
pool.isCardActive = vi.fn().mockReturnValue(true);
|
|
96
|
-
await store.insertRun(makeRun({
|
|
97
|
-
daemonPid: process.pid,
|
|
98
|
-
lastHeartbeatAt: Date.now() - 10 * 60_000,
|
|
99
|
-
}));
|
|
100
|
-
const reconciler = new Reconciler(client, pool, "p", "agent-user", ["To Do"], [], "", 60_000, store, DEFAULT_AGENT_CONFIG);
|
|
101
|
-
await reconciler.tick();
|
|
102
|
-
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
103
|
-
expect(store.getActiveRuns()).toHaveLength(1);
|
|
104
|
-
});
|
|
105
|
-
it("does not touch fresh runs even if foreign-pid checks match", async () => {
|
|
106
|
-
// Foreign daemon but pid is our own (alive) — not dead.
|
|
107
|
-
await store.insertRun(makeRun({
|
|
108
|
-
daemonPid: process.pid,
|
|
109
|
-
lastHeartbeatAt: Date.now(), // fresh
|
|
110
|
-
}));
|
|
111
|
-
pool.isCardActive = vi.fn().mockReturnValue(true);
|
|
112
|
-
const reconciler = new Reconciler(client, pool, "p", "agent-user", ["To Do"], [], "", 60_000, store, DEFAULT_AGENT_CONFIG);
|
|
113
|
-
await reconciler.tick();
|
|
114
|
-
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
115
|
-
});
|
|
116
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { recoverOrphans } from "../recovery.js";
|
|
6
|
-
import { newRunId, StateStore } from "../state-store.js";
|
|
7
|
-
import { DEFAULT_AGENT_CONFIG } from "../types.js";
|
|
8
|
-
// Mock worktree cleanup so tests don't shell out.
|
|
9
|
-
vi.mock("../worktree.js", () => ({
|
|
10
|
-
cleanupWorktree: vi.fn(),
|
|
11
|
-
}));
|
|
12
|
-
// Mock board helpers so we can stub out network.
|
|
13
|
-
vi.mock("../board-helpers.js", () => ({
|
|
14
|
-
moveCardToColumn: vi.fn().mockResolvedValue(undefined),
|
|
15
|
-
addLabelByName: vi.fn().mockResolvedValue(undefined),
|
|
16
|
-
}));
|
|
17
|
-
import { addLabelByName, moveCardToColumn } from "../board-helpers.js";
|
|
18
|
-
import { cleanupWorktree } from "../worktree.js";
|
|
19
|
-
function makeClient(overrides = {}) {
|
|
20
|
-
return {
|
|
21
|
-
endAgentSession: vi.fn().mockResolvedValue(undefined),
|
|
22
|
-
getCard: vi.fn().mockResolvedValue({
|
|
23
|
-
card: {
|
|
24
|
-
id: "card-1",
|
|
25
|
-
short_id: 42,
|
|
26
|
-
project_id: "proj-1",
|
|
27
|
-
title: "test",
|
|
28
|
-
column_id: "col-1",
|
|
29
|
-
},
|
|
30
|
-
}),
|
|
31
|
-
...overrides,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
function sampleRun(overrides = {}) {
|
|
35
|
-
return {
|
|
36
|
-
runId: newRunId(),
|
|
37
|
-
cardId: "card-1",
|
|
38
|
-
cardShortId: 42,
|
|
39
|
-
pipeline: "implement",
|
|
40
|
-
workerId: 0,
|
|
41
|
-
sessionId: null,
|
|
42
|
-
worktreePath: "/tmp/wt",
|
|
43
|
-
branchName: "agent/42-thing",
|
|
44
|
-
daemonPid: 999_999_999, // almost certainly not alive
|
|
45
|
-
phase: "running",
|
|
46
|
-
startedAt: Date.now(),
|
|
47
|
-
lastHeartbeatAt: Date.now(),
|
|
48
|
-
endedAt: null,
|
|
49
|
-
status: "active",
|
|
50
|
-
costCents: 0,
|
|
51
|
-
...overrides,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
describe("recoverOrphans", () => {
|
|
55
|
-
let dir;
|
|
56
|
-
let store;
|
|
57
|
-
beforeEach(() => {
|
|
58
|
-
dir = mkdtempSync(join(tmpdir(), "recovery-"));
|
|
59
|
-
store = new StateStore(join(dir, "state.json"));
|
|
60
|
-
vi.clearAllMocks();
|
|
61
|
-
});
|
|
62
|
-
afterEach(() => {
|
|
63
|
-
rmSync(dir, { recursive: true, force: true });
|
|
64
|
-
});
|
|
65
|
-
it("no-ops when there are no active runs", async () => {
|
|
66
|
-
const client = makeClient();
|
|
67
|
-
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
68
|
-
expect(outcomes).toEqual([]);
|
|
69
|
-
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
70
|
-
});
|
|
71
|
-
it("ends session, moves implement card, labels, and cleans worktree", async () => {
|
|
72
|
-
const run = sampleRun();
|
|
73
|
-
await store.insertRun(run);
|
|
74
|
-
const client = makeClient();
|
|
75
|
-
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
76
|
-
expect(outcomes).toHaveLength(1);
|
|
77
|
-
expect(outcomes[0].errors).toEqual([]);
|
|
78
|
-
expect(client.endAgentSession).toHaveBeenCalledWith("card-1", expect.objectContaining({ status: "paused" }));
|
|
79
|
-
expect(moveCardToColumn).toHaveBeenCalledWith(client, expect.objectContaining({ id: "card-1" }), DEFAULT_AGENT_CONFIG.pickupColumns[0]);
|
|
80
|
-
expect(addLabelByName).toHaveBeenCalledWith(client, expect.objectContaining({ id: "card-1" }), "agent-recovered", expect.any(String));
|
|
81
|
-
expect(cleanupWorktree).toHaveBeenCalledWith("/tmp/wt", "agent/42-thing");
|
|
82
|
-
expect(store.getRun(run.runId).status).toBe("orphaned");
|
|
83
|
-
});
|
|
84
|
-
it("does not move review cards back to a pickup column", async () => {
|
|
85
|
-
const run = sampleRun({ pipeline: "review" });
|
|
86
|
-
await store.insertRun(run);
|
|
87
|
-
const client = makeClient();
|
|
88
|
-
await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
89
|
-
expect(moveCardToColumn).not.toHaveBeenCalled();
|
|
90
|
-
expect(addLabelByName).toHaveBeenCalled();
|
|
91
|
-
expect(cleanupWorktree).toHaveBeenCalled();
|
|
92
|
-
});
|
|
93
|
-
it("still closes out state when the card is not reachable", async () => {
|
|
94
|
-
const run = sampleRun();
|
|
95
|
-
await store.insertRun(run);
|
|
96
|
-
const client = makeClient({
|
|
97
|
-
getCard: vi.fn().mockRejectedValue(new Error("404")),
|
|
98
|
-
});
|
|
99
|
-
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
100
|
-
expect(outcomes[0].actions).toContain("card not reachable — local cleanup only");
|
|
101
|
-
expect(cleanupWorktree).toHaveBeenCalled();
|
|
102
|
-
expect(store.getRun(run.runId).status).toBe("orphaned");
|
|
103
|
-
});
|
|
104
|
-
it("skips runs that claim a live daemon pid", async () => {
|
|
105
|
-
const run = sampleRun({ daemonPid: process.pid });
|
|
106
|
-
await store.insertRun(run);
|
|
107
|
-
const client = makeClient();
|
|
108
|
-
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
109
|
-
expect(outcomes[0].actions).toContain("skipped: daemon pid still alive");
|
|
110
|
-
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
111
|
-
expect(store.getRun(run.runId).status).toBe("active");
|
|
112
|
-
});
|
|
113
|
-
it("continues past individual API failures and records them", async () => {
|
|
114
|
-
const run = sampleRun();
|
|
115
|
-
await store.insertRun(run);
|
|
116
|
-
const client = makeClient({
|
|
117
|
-
endAgentSession: vi.fn().mockRejectedValue(new Error("boom")),
|
|
118
|
-
});
|
|
119
|
-
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
120
|
-
expect(outcomes[0].errors).toEqual([
|
|
121
|
-
expect.stringContaining("endAgentSession: boom"),
|
|
122
|
-
]);
|
|
123
|
-
expect(cleanupWorktree).toHaveBeenCalled();
|
|
124
|
-
expect(store.getRun(run.runId).status).toBe("orphaned");
|
|
125
|
-
});
|
|
126
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { parseReviewOutput } from "../review-completion.js";
|
|
3
|
-
describe("parseReviewOutput", () => {
|
|
4
|
-
it("parses verdict from ```json fenced block (strategy 1)", () => {
|
|
5
|
-
const out = `
|
|
6
|
-
Some preamble text.
|
|
7
|
-
|
|
8
|
-
\`\`\`json
|
|
9
|
-
{
|
|
10
|
-
"verdict": "approved",
|
|
11
|
-
"summary": "All good",
|
|
12
|
-
"findings": []
|
|
13
|
-
}
|
|
14
|
-
\`\`\`
|
|
15
|
-
`;
|
|
16
|
-
const r = parseReviewOutput(out);
|
|
17
|
-
expect(r.verdict).toBe("approved");
|
|
18
|
-
expect(r.summary).toBe("All good");
|
|
19
|
-
});
|
|
20
|
-
it("parses last fenced block when multiple are present", () => {
|
|
21
|
-
const out = `
|
|
22
|
-
\`\`\`json
|
|
23
|
-
{ "verdict": "rejected", "summary": "first" }
|
|
24
|
-
\`\`\`
|
|
25
|
-
|
|
26
|
-
Later correction:
|
|
27
|
-
|
|
28
|
-
\`\`\`json
|
|
29
|
-
{ "verdict": "approved", "summary": "final" }
|
|
30
|
-
\`\`\`
|
|
31
|
-
`;
|
|
32
|
-
const r = parseReviewOutput(out);
|
|
33
|
-
expect(r.verdict).toBe("approved");
|
|
34
|
-
expect(r.summary).toBe("final");
|
|
35
|
-
});
|
|
36
|
-
it("parses bare JSON object without fences (strategy 2)", () => {
|
|
37
|
-
const out = `Thinking...
|
|
38
|
-
{ "unrelated": true }
|
|
39
|
-
Result:
|
|
40
|
-
{ "verdict": "rejected", "summary": "bugs", "findings": [{ "title": "x", "severity": "major" }] }
|
|
41
|
-
`;
|
|
42
|
-
const r = parseReviewOutput(out);
|
|
43
|
-
expect(r.verdict).toBe("rejected");
|
|
44
|
-
expect(r.findings).toHaveLength(1);
|
|
45
|
-
expect(r.findings[0].title).toBe("x");
|
|
46
|
-
});
|
|
47
|
-
it("falls back to regex when JSON is malformed (strategy 3)", () => {
|
|
48
|
-
const out = `Claude rambling without proper JSON close
|
|
49
|
-
"verdict": "approved", "summary": "but missing braces everywhere
|
|
50
|
-
`;
|
|
51
|
-
const r = parseReviewOutput(out);
|
|
52
|
-
expect(r.verdict).toBe("approved");
|
|
53
|
-
expect(r.findings).toHaveLength(0);
|
|
54
|
-
});
|
|
55
|
-
it("returns error verdict when nothing parseable (strategy 4)", () => {
|
|
56
|
-
const out = "Just prose. No JSON. No verdict keyword.";
|
|
57
|
-
const r = parseReviewOutput(out);
|
|
58
|
-
expect(r.verdict).toBe("error");
|
|
59
|
-
});
|
|
60
|
-
it("handles empty output gracefully", () => {
|
|
61
|
-
const r = parseReviewOutput("");
|
|
62
|
-
expect(r.verdict).toBe("error");
|
|
63
|
-
expect(r.summary).toBe("");
|
|
64
|
-
});
|
|
65
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|