@gethmy/agent 1.3.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 +0 -0
- package/dist/review-worker.js +25 -0
- 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,132 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { newRunId, StateStore } from "../state-store.js";
|
|
6
|
-
describe("StateStore", () => {
|
|
7
|
-
let dir;
|
|
8
|
-
let path;
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
dir = mkdtempSync(join(tmpdir(), "agent-state-"));
|
|
11
|
-
path = join(dir, "state.json");
|
|
12
|
-
});
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
rmSync(dir, { recursive: true, force: true });
|
|
15
|
-
});
|
|
16
|
-
function sampleRun(overrides = {}) {
|
|
17
|
-
return {
|
|
18
|
-
runId: newRunId(),
|
|
19
|
-
cardId: "card-1",
|
|
20
|
-
cardShortId: 42,
|
|
21
|
-
pipeline: "implement",
|
|
22
|
-
workerId: 0,
|
|
23
|
-
sessionId: "sess-1",
|
|
24
|
-
worktreePath: "/tmp/wt",
|
|
25
|
-
branchName: "agent/42-thing",
|
|
26
|
-
daemonPid: process.pid,
|
|
27
|
-
phase: "preparing",
|
|
28
|
-
startedAt: Date.now(),
|
|
29
|
-
lastHeartbeatAt: Date.now(),
|
|
30
|
-
endedAt: null,
|
|
31
|
-
status: "active",
|
|
32
|
-
costCents: 0,
|
|
33
|
-
...overrides,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
it("loads empty state when file does not exist", () => {
|
|
37
|
-
const store = new StateStore(path);
|
|
38
|
-
expect(store.getActiveRuns()).toEqual([]);
|
|
39
|
-
expect(store.getDaemon()).toEqual({
|
|
40
|
-
daemonId: null,
|
|
41
|
-
daemonPid: null,
|
|
42
|
-
daemonStartedAt: null,
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
it("persists daemon metadata atomically", async () => {
|
|
46
|
-
const store = new StateStore(path);
|
|
47
|
-
await store.setDaemon("daemon-abc", 12345);
|
|
48
|
-
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
49
|
-
expect(raw.daemonId).toBe("daemon-abc");
|
|
50
|
-
expect(raw.daemonPid).toBe(12345);
|
|
51
|
-
});
|
|
52
|
-
it("inserts, heartbeats, and ends a run", async () => {
|
|
53
|
-
const store = new StateStore(path);
|
|
54
|
-
const run = sampleRun();
|
|
55
|
-
await store.insertRun(run);
|
|
56
|
-
expect(store.getActiveRuns()).toHaveLength(1);
|
|
57
|
-
expect(store.getRun(run.runId)?.cardId).toBe("card-1");
|
|
58
|
-
const before = store.getRun(run.runId).lastHeartbeatAt;
|
|
59
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
60
|
-
await store.heartbeat(run.runId);
|
|
61
|
-
expect(store.getRun(run.runId).lastHeartbeatAt).toBeGreaterThan(before);
|
|
62
|
-
await store.endRun(run.runId, "completed");
|
|
63
|
-
expect(store.getActiveRuns()).toHaveLength(0);
|
|
64
|
-
expect(store.getRun(run.runId).status).toBe("completed");
|
|
65
|
-
expect(store.getRun(run.runId).endedAt).not.toBeNull();
|
|
66
|
-
});
|
|
67
|
-
it("reloads persisted state after reopen", async () => {
|
|
68
|
-
const store = new StateStore(path);
|
|
69
|
-
await store.setDaemon("d1", 1);
|
|
70
|
-
await store.insertRun(sampleRun({ cardId: "card-7" }));
|
|
71
|
-
const reopened = new StateStore(path);
|
|
72
|
-
expect(reopened.getDaemon().daemonId).toBe("d1");
|
|
73
|
-
expect(reopened.getActiveRuns()[0].cardId).toBe("card-7");
|
|
74
|
-
});
|
|
75
|
-
it("tracks card attempts and resets on success", async () => {
|
|
76
|
-
const store = new StateStore(path);
|
|
77
|
-
expect(await store.incrementAttempt("c1")).toBe(1);
|
|
78
|
-
expect(await store.incrementAttempt("c1")).toBe(2);
|
|
79
|
-
await store.recordOutcome("c1", "failure");
|
|
80
|
-
expect(store.getCard("c1").attempts).toBe(2);
|
|
81
|
-
await store.recordOutcome("c1", "success");
|
|
82
|
-
expect(store.getCard("c1").attempts).toBe(0);
|
|
83
|
-
});
|
|
84
|
-
it("aggregates cost per card and per UTC day", async () => {
|
|
85
|
-
const store = new StateStore(path);
|
|
86
|
-
await store.addCost("c1", 100);
|
|
87
|
-
await store.addCost("c1", 250);
|
|
88
|
-
await store.addCost("c2", 50);
|
|
89
|
-
expect(store.getCard("c1").totalCostCents).toBe(350);
|
|
90
|
-
expect(store.getCard("c2").totalCostCents).toBe(50);
|
|
91
|
-
expect(store.getDailyCostCents()).toBe(400);
|
|
92
|
-
});
|
|
93
|
-
it("manages DLQ status", async () => {
|
|
94
|
-
const store = new StateStore(path);
|
|
95
|
-
expect(store.isDlq("c1")).toBe(false);
|
|
96
|
-
await store.markDlq("c1", "build failed 3x");
|
|
97
|
-
expect(store.isDlq("c1")).toBe(true);
|
|
98
|
-
expect(store.listDlq()).toHaveLength(1);
|
|
99
|
-
expect(store.getCard("c1").dlqReason).toBe("build failed 3x");
|
|
100
|
-
await store.clearDlq("c1");
|
|
101
|
-
expect(store.isDlq("c1")).toBe(false);
|
|
102
|
-
expect(store.listDlq()).toHaveLength(0);
|
|
103
|
-
});
|
|
104
|
-
it("serializes concurrent writes via the write queue", async () => {
|
|
105
|
-
const store = new StateStore(path);
|
|
106
|
-
const run = sampleRun();
|
|
107
|
-
await store.insertRun(run);
|
|
108
|
-
await Promise.all([
|
|
109
|
-
store.heartbeat(run.runId),
|
|
110
|
-
store.updateRun(run.runId, { phase: "running" }),
|
|
111
|
-
store.addCost(run.cardId, 10),
|
|
112
|
-
store.addCost(run.cardId, 20),
|
|
113
|
-
]);
|
|
114
|
-
await store.flush();
|
|
115
|
-
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
116
|
-
expect(raw.runs[0].phase).toBe("running");
|
|
117
|
-
expect(raw.cards.find((c) => c.cardId === run.cardId)
|
|
118
|
-
.totalCostCents).toBe(30);
|
|
119
|
-
});
|
|
120
|
-
it("handles corrupted state files by starting fresh", () => {
|
|
121
|
-
const brokenPath = join(dir, "broken.json");
|
|
122
|
-
require("node:fs").writeFileSync(brokenPath, "{not valid json", "utf-8");
|
|
123
|
-
const store = new StateStore(brokenPath);
|
|
124
|
-
expect(store.getActiveRuns()).toEqual([]);
|
|
125
|
-
});
|
|
126
|
-
it("migrates away from unknown schema versions", () => {
|
|
127
|
-
const future = join(dir, "future.json");
|
|
128
|
-
require("node:fs").writeFileSync(future, JSON.stringify({ version: 99, runs: [{}] }), "utf-8");
|
|
129
|
-
const store = new StateStore(future);
|
|
130
|
-
expect(store.getActiveRuns()).toEqual([]);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { StreamParser } from "../stream-parser.js";
|
|
3
|
-
import { verifyStreamParserFormat } from "../stream-parser-selftest.js";
|
|
4
|
-
describe("verifyStreamParserFormat", () => {
|
|
5
|
-
it("passes with the current stream-parser implementation", () => {
|
|
6
|
-
expect(() => verifyStreamParserFormat()).not.toThrow();
|
|
7
|
-
});
|
|
8
|
-
it("throws a descriptive error if the parser is silently broken", () => {
|
|
9
|
-
// Simulate CLI-format drift: stub StreamParser so `feed` is a no-op.
|
|
10
|
-
// The canary should notice that no events fired and fail loud.
|
|
11
|
-
const spy = vi
|
|
12
|
-
.spyOn(StreamParser.prototype, "feed")
|
|
13
|
-
.mockImplementation(() => {
|
|
14
|
-
// drop every line
|
|
15
|
-
});
|
|
16
|
-
try {
|
|
17
|
-
expect(() => verifyStreamParserFormat()).toThrow(/canary failed/i);
|
|
18
|
-
}
|
|
19
|
-
finally {
|
|
20
|
-
spy.mockRestore();
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { StreamParser } from "../stream-parser.js";
|
|
3
|
-
function collect(parser) {
|
|
4
|
-
const texts = [];
|
|
5
|
-
const toolStarts = [];
|
|
6
|
-
const toolEnds = [];
|
|
7
|
-
const results = [];
|
|
8
|
-
const costs = [];
|
|
9
|
-
const errors = [];
|
|
10
|
-
parser.on("text", (c) => texts.push(c));
|
|
11
|
-
parser.on("tool_start", (name, input) => toolStarts.push({ name, input }));
|
|
12
|
-
parser.on("tool_end", (name, id, content) => toolEnds.push({ name, id, content }));
|
|
13
|
-
parser.on("result", (stop) => results.push(stop));
|
|
14
|
-
parser.on("cost_update", (c) => costs.push(c));
|
|
15
|
-
parser.on("parse_error", (m) => errors.push(m));
|
|
16
|
-
return { texts, toolStarts, toolEnds, results, costs, errors };
|
|
17
|
-
}
|
|
18
|
-
describe("StreamParser", () => {
|
|
19
|
-
it("emits text from assistant content blocks (Claude CLI stream-json format)", () => {
|
|
20
|
-
const parser = new StreamParser();
|
|
21
|
-
const events = collect(parser);
|
|
22
|
-
parser.feed(`${JSON.stringify({
|
|
23
|
-
type: "assistant",
|
|
24
|
-
message: {
|
|
25
|
-
content: [
|
|
26
|
-
{
|
|
27
|
-
type: "text",
|
|
28
|
-
text: '```json\n{"verdict":"approved","summary":"ok"}\n```',
|
|
29
|
-
},
|
|
30
|
-
],
|
|
31
|
-
},
|
|
32
|
-
})}\n`);
|
|
33
|
-
expect(events.texts).toHaveLength(1);
|
|
34
|
-
expect(events.texts[0]).toContain('"verdict":"approved"');
|
|
35
|
-
});
|
|
36
|
-
it("emits tool_start for tool_use blocks and tool_end for tool_result with matching name", () => {
|
|
37
|
-
const parser = new StreamParser();
|
|
38
|
-
const events = collect(parser);
|
|
39
|
-
parser.feed(`${JSON.stringify({
|
|
40
|
-
type: "assistant",
|
|
41
|
-
message: {
|
|
42
|
-
content: [
|
|
43
|
-
{
|
|
44
|
-
type: "tool_use",
|
|
45
|
-
id: "toolu_1",
|
|
46
|
-
name: "Bash",
|
|
47
|
-
input: { command: "ls" },
|
|
48
|
-
},
|
|
49
|
-
],
|
|
50
|
-
},
|
|
51
|
-
})}\n`);
|
|
52
|
-
parser.feed(`${JSON.stringify({
|
|
53
|
-
type: "user",
|
|
54
|
-
message: {
|
|
55
|
-
content: [
|
|
56
|
-
{
|
|
57
|
-
type: "tool_result",
|
|
58
|
-
tool_use_id: "toolu_1",
|
|
59
|
-
content: "file1\nfile2",
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
},
|
|
63
|
-
})}\n`);
|
|
64
|
-
expect(events.toolStarts).toEqual([
|
|
65
|
-
{ name: "Bash", input: { command: "ls" } },
|
|
66
|
-
]);
|
|
67
|
-
expect(events.toolEnds).toEqual([
|
|
68
|
-
{ name: "Bash", id: "toolu_1", content: "file1\nfile2" },
|
|
69
|
-
]);
|
|
70
|
-
});
|
|
71
|
-
it("ignores thinking blocks", () => {
|
|
72
|
-
const parser = new StreamParser();
|
|
73
|
-
const events = collect(parser);
|
|
74
|
-
parser.feed(`${JSON.stringify({
|
|
75
|
-
type: "assistant",
|
|
76
|
-
message: {
|
|
77
|
-
content: [
|
|
78
|
-
{ type: "thinking", thinking: "pondering..." },
|
|
79
|
-
{ type: "text", text: "actual output" },
|
|
80
|
-
],
|
|
81
|
-
},
|
|
82
|
-
})}\n`);
|
|
83
|
-
expect(events.texts).toEqual(["actual output"]);
|
|
84
|
-
});
|
|
85
|
-
it("falls back to result.result when no assistant text was streamed", () => {
|
|
86
|
-
const parser = new StreamParser();
|
|
87
|
-
const events = collect(parser);
|
|
88
|
-
parser.feed(`${JSON.stringify({
|
|
89
|
-
type: "result",
|
|
90
|
-
subtype: "success",
|
|
91
|
-
result: "final verdict text",
|
|
92
|
-
total_cost_usd: 0.5,
|
|
93
|
-
usage: {
|
|
94
|
-
input_tokens: 10,
|
|
95
|
-
output_tokens: 20,
|
|
96
|
-
cache_read_input_tokens: 100,
|
|
97
|
-
cache_creation_input_tokens: 5,
|
|
98
|
-
},
|
|
99
|
-
duration_ms: 1000,
|
|
100
|
-
duration_api_ms: 900,
|
|
101
|
-
num_turns: 3,
|
|
102
|
-
})}\n`);
|
|
103
|
-
expect(events.texts).toEqual(["final verdict text"]);
|
|
104
|
-
expect(events.costs).toEqual([
|
|
105
|
-
{
|
|
106
|
-
totalCostUsd: 0.5,
|
|
107
|
-
totalInputTokens: 10,
|
|
108
|
-
totalOutputTokens: 20,
|
|
109
|
-
totalCacheCreationInputTokens: 5,
|
|
110
|
-
totalCacheReadInputTokens: 100,
|
|
111
|
-
durationMs: 1000,
|
|
112
|
-
durationApiMs: 900,
|
|
113
|
-
numTurns: 3,
|
|
114
|
-
modelName: undefined,
|
|
115
|
-
},
|
|
116
|
-
]);
|
|
117
|
-
expect(events.results).toEqual(["success"]);
|
|
118
|
-
});
|
|
119
|
-
it("does NOT duplicate text from result.result when assistant text was already emitted", () => {
|
|
120
|
-
const parser = new StreamParser();
|
|
121
|
-
const events = collect(parser);
|
|
122
|
-
parser.feed(`${JSON.stringify({
|
|
123
|
-
type: "assistant",
|
|
124
|
-
message: {
|
|
125
|
-
content: [{ type: "text", text: "the verdict" }],
|
|
126
|
-
},
|
|
127
|
-
})}\n${JSON.stringify({
|
|
128
|
-
type: "result",
|
|
129
|
-
subtype: "success",
|
|
130
|
-
result: "the verdict",
|
|
131
|
-
total_cost_usd: 0,
|
|
132
|
-
})}\n`);
|
|
133
|
-
expect(events.texts).toEqual(["the verdict"]);
|
|
134
|
-
});
|
|
135
|
-
it("handles split NDJSON chunks (partial line buffering)", () => {
|
|
136
|
-
const parser = new StreamParser();
|
|
137
|
-
const events = collect(parser);
|
|
138
|
-
const line = JSON.stringify({
|
|
139
|
-
type: "assistant",
|
|
140
|
-
message: { content: [{ type: "text", text: "hello" }] },
|
|
141
|
-
});
|
|
142
|
-
parser.feed(line.slice(0, 20));
|
|
143
|
-
parser.feed(`${line.slice(20)}\n`);
|
|
144
|
-
expect(events.texts).toEqual(["hello"]);
|
|
145
|
-
});
|
|
146
|
-
it("handles tool_result content as array of blocks", () => {
|
|
147
|
-
const parser = new StreamParser();
|
|
148
|
-
const events = collect(parser);
|
|
149
|
-
parser.feed(`${JSON.stringify({
|
|
150
|
-
type: "assistant",
|
|
151
|
-
message: {
|
|
152
|
-
content: [{ type: "tool_use", id: "t1", name: "Read", input: {} }],
|
|
153
|
-
},
|
|
154
|
-
})}\n${JSON.stringify({
|
|
155
|
-
type: "user",
|
|
156
|
-
message: {
|
|
157
|
-
content: [
|
|
158
|
-
{
|
|
159
|
-
type: "tool_result",
|
|
160
|
-
tool_use_id: "t1",
|
|
161
|
-
content: [{ type: "text", text: "line1" }],
|
|
162
|
-
},
|
|
163
|
-
],
|
|
164
|
-
},
|
|
165
|
-
})}\n`);
|
|
166
|
-
expect(events.toolEnds[0].content).toBe("line1");
|
|
167
|
-
});
|
|
168
|
-
it("skips non-JSON lines silently (no parse_error event)", () => {
|
|
169
|
-
const parser = new StreamParser();
|
|
170
|
-
const events = collect(parser);
|
|
171
|
-
parser.feed("not-json garbage\n");
|
|
172
|
-
parser.feed(`${JSON.stringify({
|
|
173
|
-
type: "assistant",
|
|
174
|
-
message: { content: [{ type: "text", text: "ok" }] },
|
|
175
|
-
})}\n`);
|
|
176
|
-
expect(events.errors).toHaveLength(0);
|
|
177
|
-
expect(events.texts).toEqual(["ok"]);
|
|
178
|
-
});
|
|
179
|
-
it("throws if attached twice", async () => {
|
|
180
|
-
const parser = new StreamParser();
|
|
181
|
-
const { Readable } = await import("node:stream");
|
|
182
|
-
parser.attach(Readable.from([]));
|
|
183
|
-
expect(() => parser.attach(Readable.from([]))).toThrow("StreamParser already attached");
|
|
184
|
-
});
|
|
185
|
-
it("reconstructs the full verdict JSON across multiple streamed text blocks", () => {
|
|
186
|
-
// Simulates the common case where Claude streams a final message with
|
|
187
|
-
// a preamble + fenced JSON block in a single text block. The StreamParser
|
|
188
|
-
// MUST surface that text so parseReviewOutput can pick up the verdict.
|
|
189
|
-
const parser = new StreamParser();
|
|
190
|
-
const events = collect(parser);
|
|
191
|
-
const finalText = 'All findings complete. Outputting verdict.\n\n```json\n{\n "verdict": "approved",\n "summary": "ok",\n "findings": []\n}\n```';
|
|
192
|
-
parser.feed(`${JSON.stringify({
|
|
193
|
-
type: "assistant",
|
|
194
|
-
message: { content: [{ type: "text", text: finalText }] },
|
|
195
|
-
})}\n`);
|
|
196
|
-
const joined = events.texts.join("");
|
|
197
|
-
expect(joined).toContain('"verdict": "approved"');
|
|
198
|
-
});
|
|
199
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { runTransition, TransitionError } from "../transitions.js";
|
|
3
|
-
function makeBoard() {
|
|
4
|
-
return {
|
|
5
|
-
columns: [
|
|
6
|
-
{ id: "col-todo", name: "To Do" },
|
|
7
|
-
{ id: "col-progress", name: "In Progress" },
|
|
8
|
-
{ id: "col-review", name: "Review" },
|
|
9
|
-
{ id: "col-done", name: "Done" },
|
|
10
|
-
],
|
|
11
|
-
labels: [
|
|
12
|
-
{ id: "lbl-agent", name: "agent" },
|
|
13
|
-
{ id: "lbl-dlq", name: "dlq" },
|
|
14
|
-
],
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
function makeCard(overrides = {}) {
|
|
18
|
-
return {
|
|
19
|
-
id: "card-1",
|
|
20
|
-
short_id: 42,
|
|
21
|
-
project_id: "proj",
|
|
22
|
-
title: "t",
|
|
23
|
-
column_id: "col-todo",
|
|
24
|
-
labelIds: [],
|
|
25
|
-
...overrides,
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
function makeClient(overrides = {}) {
|
|
29
|
-
return {
|
|
30
|
-
getBoard: vi.fn().mockResolvedValue(makeBoard()),
|
|
31
|
-
moveCard: vi.fn().mockResolvedValue({}),
|
|
32
|
-
addLabelToCard: vi.fn().mockResolvedValue({}),
|
|
33
|
-
removeLabelFromCard: vi.fn().mockResolvedValue({}),
|
|
34
|
-
createLabel: vi.fn().mockResolvedValue({ label: { id: "lbl-new" } }),
|
|
35
|
-
updateCard: vi.fn().mockResolvedValue({}),
|
|
36
|
-
endAgentSession: vi.fn().mockResolvedValue({}),
|
|
37
|
-
...overrides,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
describe("runTransition", () => {
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
vi.clearAllMocks();
|
|
43
|
-
});
|
|
44
|
-
it("moves, labels, and ends session in order", async () => {
|
|
45
|
-
const client = makeClient();
|
|
46
|
-
const card = makeCard();
|
|
47
|
-
await runTransition(client, card, {
|
|
48
|
-
move: { columnName: "In Progress" },
|
|
49
|
-
addLabels: [{ name: "agent" }],
|
|
50
|
-
endSession: { status: "completed", progressPercent: 100 },
|
|
51
|
-
});
|
|
52
|
-
expect(client.moveCard).toHaveBeenCalledWith("card-1", "col-progress");
|
|
53
|
-
expect(client.addLabelToCard).toHaveBeenCalledWith("card-1", "lbl-agent");
|
|
54
|
-
expect(client.endAgentSession).toHaveBeenCalledWith("card-1", {
|
|
55
|
-
status: "completed",
|
|
56
|
-
progressPercent: 100,
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
it("is idempotent when the card is already in the target column", async () => {
|
|
60
|
-
const client = makeClient();
|
|
61
|
-
const card = makeCard({ column_id: "col-progress" });
|
|
62
|
-
await runTransition(client, card, { move: { columnName: "In Progress" } });
|
|
63
|
-
expect(client.moveCard).not.toHaveBeenCalled();
|
|
64
|
-
});
|
|
65
|
-
it("skips adding a label the card already has", async () => {
|
|
66
|
-
const client = makeClient();
|
|
67
|
-
const card = makeCard({ labelIds: ["lbl-agent"] });
|
|
68
|
-
await runTransition(client, card, { addLabels: [{ name: "agent" }] });
|
|
69
|
-
expect(client.addLabelToCard).not.toHaveBeenCalled();
|
|
70
|
-
});
|
|
71
|
-
it("creates missing labels on demand", async () => {
|
|
72
|
-
const client = makeClient();
|
|
73
|
-
const card = makeCard();
|
|
74
|
-
await runTransition(client, card, {
|
|
75
|
-
addLabels: [{ name: "agent-recovered", color: "#ff0000" }],
|
|
76
|
-
});
|
|
77
|
-
expect(client.createLabel).toHaveBeenCalledWith("proj", {
|
|
78
|
-
name: "agent-recovered",
|
|
79
|
-
color: "#ff0000",
|
|
80
|
-
});
|
|
81
|
-
expect(client.addLabelToCard).toHaveBeenCalledWith("card-1", "lbl-new");
|
|
82
|
-
});
|
|
83
|
-
it("removes labels idempotently", async () => {
|
|
84
|
-
const client = makeClient();
|
|
85
|
-
const card = makeCard({ labelIds: ["lbl-agent", "lbl-dlq"] });
|
|
86
|
-
await runTransition(client, card, { removeLabels: ["agent", "nope"] });
|
|
87
|
-
expect(client.removeLabelFromCard).toHaveBeenCalledTimes(1);
|
|
88
|
-
expect(client.removeLabelFromCard).toHaveBeenCalledWith("card-1", "lbl-agent");
|
|
89
|
-
});
|
|
90
|
-
it("retries a flaky step before giving up", async () => {
|
|
91
|
-
const moveCard = vi
|
|
92
|
-
.fn()
|
|
93
|
-
.mockRejectedValueOnce(new Error("502"))
|
|
94
|
-
.mockRejectedValueOnce(new Error("502"))
|
|
95
|
-
.mockResolvedValueOnce({});
|
|
96
|
-
const client = makeClient({ moveCard });
|
|
97
|
-
await runTransition(client, makeCard(), { move: { columnName: "Review" } }, { retries: 3, backoffMs: 1 });
|
|
98
|
-
expect(moveCard).toHaveBeenCalledTimes(3);
|
|
99
|
-
});
|
|
100
|
-
it("throws TransitionError with the failing step after retries exhausted", async () => {
|
|
101
|
-
const endAgentSession = vi
|
|
102
|
-
.fn()
|
|
103
|
-
.mockRejectedValue(new Error("upstream down"));
|
|
104
|
-
const client = makeClient({ endAgentSession });
|
|
105
|
-
await expect(runTransition(client, makeCard(), { endSession: { status: "paused" } }, { retries: 2, backoffMs: 1 })).rejects.toMatchObject({
|
|
106
|
-
name: "TransitionError",
|
|
107
|
-
step: "endSession",
|
|
108
|
-
attempts: 2,
|
|
109
|
-
});
|
|
110
|
-
expect(endAgentSession).toHaveBeenCalledTimes(2);
|
|
111
|
-
});
|
|
112
|
-
it("warns but continues when target column is missing in non-strict mode", async () => {
|
|
113
|
-
const client = makeClient();
|
|
114
|
-
await expect(runTransition(client, makeCard(), {
|
|
115
|
-
move: { columnName: "No Such Column" },
|
|
116
|
-
})).resolves.toBeUndefined();
|
|
117
|
-
expect(client.moveCard).not.toHaveBeenCalled();
|
|
118
|
-
});
|
|
119
|
-
it("throws when target column is missing in strict mode", async () => {
|
|
120
|
-
const client = makeClient();
|
|
121
|
-
await expect(runTransition(client, makeCard(), { move: { columnName: "No Such Column" } }, { strictColumn: true })).rejects.toBeInstanceOf(TransitionError);
|
|
122
|
-
});
|
|
123
|
-
it("heartbeats the state store when wired", async () => {
|
|
124
|
-
const client = makeClient();
|
|
125
|
-
const heartbeat = vi.fn().mockResolvedValue(undefined);
|
|
126
|
-
const store = { heartbeat };
|
|
127
|
-
await runTransition(client, makeCard(), { move: { columnName: "Done" } }, { store, runId: "r_1" });
|
|
128
|
-
expect(heartbeat).toHaveBeenCalledWith("r_1");
|
|
129
|
-
});
|
|
130
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
vi.mock("../worktree.js", () => ({
|
|
7
|
-
cleanupWorktree: vi.fn((path) => {
|
|
8
|
-
// Make cleanupWorktree actually remove the directory so our
|
|
9
|
-
// assertions reflect reality without invoking git.
|
|
10
|
-
rmSync(path, { recursive: true, force: true });
|
|
11
|
-
}),
|
|
12
|
-
}));
|
|
13
|
-
import { newRunId, StateStore } from "../state-store.js";
|
|
14
|
-
import { cleanupWorktree } from "../worktree.js";
|
|
15
|
-
import { runWorktreeGc } from "../worktree-gc.js";
|
|
16
|
-
function initRepo(dir) {
|
|
17
|
-
execFileSync("git", ["init", "-q", dir], { stdio: "pipe" });
|
|
18
|
-
execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", "init"], {
|
|
19
|
-
stdio: "pipe",
|
|
20
|
-
env: {
|
|
21
|
-
...process.env,
|
|
22
|
-
GIT_AUTHOR_NAME: "t",
|
|
23
|
-
GIT_AUTHOR_EMAIL: "t@t",
|
|
24
|
-
GIT_COMMITTER_NAME: "t",
|
|
25
|
-
GIT_COMMITTER_EMAIL: "t@t",
|
|
26
|
-
},
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
describe("runWorktreeGc", () => {
|
|
30
|
-
let repoDir;
|
|
31
|
-
let store;
|
|
32
|
-
beforeEach(() => {
|
|
33
|
-
// Realpath to collapse the /tmp → /private/tmp symlink on macOS so
|
|
34
|
-
// paths match what `git rev-parse --show-toplevel` returns.
|
|
35
|
-
repoDir = realpathSync(mkdtempSync(join(tmpdir(), "gc-repo-")));
|
|
36
|
-
initRepo(repoDir);
|
|
37
|
-
const stateDir = mkdtempSync(join(tmpdir(), "gc-state-"));
|
|
38
|
-
store = new StateStore(join(stateDir, "state.json"));
|
|
39
|
-
vi.clearAllMocks();
|
|
40
|
-
});
|
|
41
|
-
afterEach(() => {
|
|
42
|
-
rmSync(repoDir, { recursive: true, force: true });
|
|
43
|
-
});
|
|
44
|
-
function makeWorktreeDir(name, ageMs = 0) {
|
|
45
|
-
const dir = join(repoDir, ".harmony-worktrees", name);
|
|
46
|
-
mkdirSync(dir, { recursive: true });
|
|
47
|
-
writeFileSync(join(dir, "marker"), "x");
|
|
48
|
-
if (ageMs > 0) {
|
|
49
|
-
const mtime = new Date(Date.now() - ageMs);
|
|
50
|
-
execFileSync("touch", ["-m", "-t", formatMtime(mtime), dir]);
|
|
51
|
-
}
|
|
52
|
-
return dir;
|
|
53
|
-
}
|
|
54
|
-
function formatMtime(d) {
|
|
55
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
56
|
-
return (d.getFullYear().toString() +
|
|
57
|
-
pad(d.getMonth() + 1) +
|
|
58
|
-
pad(d.getDate()) +
|
|
59
|
-
pad(d.getHours()) +
|
|
60
|
-
pad(d.getMinutes()));
|
|
61
|
-
}
|
|
62
|
-
it("removes orphan worktrees older than minAgeMs", () => {
|
|
63
|
-
const cwd = process.cwd();
|
|
64
|
-
process.chdir(repoDir);
|
|
65
|
-
try {
|
|
66
|
-
makeWorktreeDir("agent-42-old", 2 * 60 * 60 * 1000);
|
|
67
|
-
const result = runWorktreeGc(".harmony-worktrees", store, {
|
|
68
|
-
minAgeMs: 60 * 60 * 1000,
|
|
69
|
-
});
|
|
70
|
-
expect(result.removed).toHaveLength(1);
|
|
71
|
-
expect(cleanupWorktree).toHaveBeenCalledTimes(1);
|
|
72
|
-
}
|
|
73
|
-
finally {
|
|
74
|
-
process.chdir(cwd);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
it("keeps worktrees that match an active run", async () => {
|
|
78
|
-
const cwd = process.cwd();
|
|
79
|
-
process.chdir(repoDir);
|
|
80
|
-
try {
|
|
81
|
-
const wtPath = makeWorktreeDir("agent-99", 2 * 60 * 60 * 1000);
|
|
82
|
-
await store.insertRun({
|
|
83
|
-
runId: newRunId(),
|
|
84
|
-
cardId: "c1",
|
|
85
|
-
cardShortId: 99,
|
|
86
|
-
pipeline: "implement",
|
|
87
|
-
workerId: 0,
|
|
88
|
-
sessionId: null,
|
|
89
|
-
worktreePath: wtPath,
|
|
90
|
-
branchName: "agent/99",
|
|
91
|
-
daemonPid: process.pid,
|
|
92
|
-
phase: "running",
|
|
93
|
-
startedAt: Date.now(),
|
|
94
|
-
lastHeartbeatAt: Date.now(),
|
|
95
|
-
endedAt: null,
|
|
96
|
-
status: "active",
|
|
97
|
-
costCents: 0,
|
|
98
|
-
});
|
|
99
|
-
const result = runWorktreeGc(".harmony-worktrees", store, {
|
|
100
|
-
minAgeMs: 60 * 60 * 1000,
|
|
101
|
-
});
|
|
102
|
-
expect(result.removed).toHaveLength(0);
|
|
103
|
-
expect(result.skipped).toContain(wtPath);
|
|
104
|
-
expect(cleanupWorktree).not.toHaveBeenCalled();
|
|
105
|
-
}
|
|
106
|
-
finally {
|
|
107
|
-
process.chdir(cwd);
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
it("keeps worktrees younger than minAgeMs even if unclaimed", () => {
|
|
111
|
-
const cwd = process.cwd();
|
|
112
|
-
process.chdir(repoDir);
|
|
113
|
-
try {
|
|
114
|
-
const wt = makeWorktreeDir("agent-fresh", 0);
|
|
115
|
-
const result = runWorktreeGc(".harmony-worktrees", store, {
|
|
116
|
-
minAgeMs: 60 * 60 * 1000,
|
|
117
|
-
});
|
|
118
|
-
expect(result.removed).toHaveLength(0);
|
|
119
|
-
expect(result.skipped).toContain(wt);
|
|
120
|
-
}
|
|
121
|
-
finally {
|
|
122
|
-
process.chdir(cwd);
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
it("returns cleanly when the worktrees directory doesn't exist yet", () => {
|
|
126
|
-
const cwd = process.cwd();
|
|
127
|
-
process.chdir(repoDir);
|
|
128
|
-
try {
|
|
129
|
-
const result = runWorktreeGc(".harmony-worktrees", store);
|
|
130
|
-
expect(result.checked).toBe(0);
|
|
131
|
-
expect(result.errors).toEqual([]);
|
|
132
|
-
}
|
|
133
|
-
finally {
|
|
134
|
-
process.chdir(cwd);
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
});
|