@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/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Push-based agent daemon for [Harmony](https://gethmy.com). Watches board assignments via Supabase Realtime and autonomously implements and reviews cards using Claude CLI workers in isolated git worktrees.
|
|
4
4
|
|
|
5
|
+
Built for **failsafe auto mode**: crashed daemons recover on restart, misconfigured columns fail fast, runaway costs trip a daily budget, and cards that can't pass build land in a dead-letter queue instead of bouncing forever.
|
|
6
|
+
|
|
5
7
|
## Prerequisites
|
|
6
8
|
|
|
7
9
|
- [Node.js](https://nodejs.org) >= 20 or [Bun](https://bun.sh) >= 1.0
|
|
@@ -29,39 +31,88 @@ harmony-agent
|
|
|
29
31
|
npx @gethmy/mcp setup
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
2. Add agent config to `~/.harmony-mcp/config.json
|
|
34
|
+
2. Add agent config to `~/.harmony-mcp/config.json`. Every field is optional — the snippet below shows the common options with their defaults. For the full schema (worktree, verification, completion, priority labels), see [docs/agent-daemon.md](../../docs/agent-daemon.md):
|
|
33
35
|
|
|
34
|
-
```
|
|
36
|
+
```jsonc
|
|
35
37
|
{
|
|
36
38
|
"agent": {
|
|
37
39
|
"poolSize": 1,
|
|
38
40
|
"pickupColumns": ["To Do"],
|
|
39
|
-
"claude": {
|
|
40
|
-
"model": "sonnet"
|
|
41
|
-
},
|
|
41
|
+
"claude": { "model": "opus", "reviewModel": "sonnet" },
|
|
42
42
|
"review": {
|
|
43
43
|
"enabled": true,
|
|
44
44
|
"pickupColumns": ["Review"],
|
|
45
45
|
"moveToColumn": "Done",
|
|
46
46
|
"failColumn": "To Do"
|
|
47
|
+
},
|
|
48
|
+
"budget": {
|
|
49
|
+
"maxAttemptsPerCard": 3, // DLQ after N full failed runs
|
|
50
|
+
"maxCentsPerCard": 500, // $5.00 per-card cost cap
|
|
51
|
+
"dailyBudgetCents": 5000, // $50.00 total daily cap
|
|
52
|
+
"dlqLabel": "dlq"
|
|
53
|
+
},
|
|
54
|
+
"http": {
|
|
55
|
+
"enabled": true,
|
|
56
|
+
"port": 47821,
|
|
57
|
+
"bindAddr": "127.0.0.1"
|
|
58
|
+
},
|
|
59
|
+
"timing": {
|
|
60
|
+
"heartbeatMs": 30000,
|
|
61
|
+
"staleHeartbeatMs": 120000,
|
|
62
|
+
"reconcileIntervalMs": 60000,
|
|
63
|
+
"worktreeGcIntervalMs": 300000
|
|
47
64
|
}
|
|
48
65
|
}
|
|
49
66
|
}
|
|
50
67
|
```
|
|
51
68
|
|
|
52
|
-
##
|
|
53
|
-
|
|
54
|
-
Run from your git repository root:
|
|
69
|
+
## CLI
|
|
55
70
|
|
|
56
71
|
```bash
|
|
57
|
-
|
|
72
|
+
harmony-agent [run] # Start the daemon (default)
|
|
73
|
+
harmony-agent status # Snapshot: workers, queues, DLQ, budget
|
|
74
|
+
harmony-agent health # Exit 0 if healthy, 1 otherwise
|
|
75
|
+
harmony-agent doctor # Preflight checks without starting the daemon
|
|
76
|
+
harmony-agent gc # One-shot worktree garbage collection
|
|
77
|
+
harmony-agent dlq list # List dead-lettered cards
|
|
78
|
+
harmony-agent dlq clear <cardId> # Release a card from the DLQ
|
|
79
|
+
harmony-agent help # Show usage
|
|
80
|
+
|
|
81
|
+
# Flags:
|
|
82
|
+
--pretty # Force colored human log output
|
|
83
|
+
--json # Force JSON (one record per line)
|
|
84
|
+
# Default: pretty on a TTY, JSON when piped
|
|
58
85
|
```
|
|
59
86
|
|
|
60
|
-
|
|
87
|
+
`status`, `health`, and `dlq clear` route through the running daemon's HTTP server (`127.0.0.1:47821` by default). `dlq clear` falls back to a direct state-store write only when the daemon is offline.
|
|
88
|
+
|
|
89
|
+
## What the daemon does
|
|
90
|
+
|
|
91
|
+
1. **Watches** the board in real time via Supabase Realtime.
|
|
92
|
+
2. **Picks up** cards assigned to your agent user from configured columns.
|
|
93
|
+
3. **Implements** changes in an isolated git worktree using a full-tool Claude run.
|
|
94
|
+
4. **Verifies** the build + lint pass. Failures trigger an auto-fix attempt. Persistent failures move the card back to the `verification.failColumn` (default: `To Do`).
|
|
95
|
+
5. **Pushes** the branch and moves the card to `Review`.
|
|
96
|
+
6. **Reviews** the diff with a read-only Claude run against a live dev server. Verdict is either `approved` (PR created, `Ready to Merge` label) or `rejected` (findings posted, card moved back to pickup).
|
|
97
|
+
|
|
98
|
+
## Guarantees
|
|
99
|
+
|
|
100
|
+
- **Resumable.** A crash never orphans a card. On restart the daemon reconciles its own ghosts, returns implement cards to the pickup column with an `agent-recovered` label, ends their Harmony sessions, and cleans up worktrees.
|
|
101
|
+
- **Trustworthy.** The review worker refuses to approve if the dev server didn't respond to an HTTP probe. Infrastructure failures are distinguished from code failures, so valid PRs don't get bounced back to rework.
|
|
102
|
+
- **Bounded.** Per-card attempt and cost caps plus a daily budget cap prevent runaway spend. When caps hit, cards go to a dead-letter queue with a label instead of silently re-queuing.
|
|
103
|
+
- **Observable.** Structured JSON logs on stderr (pipe to `jq`), a local HTTP status endpoint, and per-worker heartbeats so the reconciler can detect zombie runs within 2 minutes.
|
|
104
|
+
- **Safe.** Claude subprocesses run in their own process group. Cancellation (SIGINT → SIGTERM → SIGKILL) terminates the whole subprocess tree, including build tools and dev servers Claude spawned.
|
|
105
|
+
|
|
106
|
+
## State
|
|
107
|
+
|
|
108
|
+
Durable state lives at `~/.harmony-mcp/agent-state.json`. Tracks live runs, per-card attempts and costs, daily spend, and DLQ markers. Atomic writes via write-to-tmp + rename.
|
|
109
|
+
|
|
110
|
+
Worktrees live at `.harmony-worktrees/` inside your repo. A background sweep every 5 minutes removes directories older than 1 hour that no live run claims.
|
|
111
|
+
|
|
112
|
+
## Debugging
|
|
113
|
+
|
|
114
|
+
Every Claude CLI run writes a per-run log at `~/.harmony-mcp/runs/<runId>-card-<shortId>.log` — full stdout (NDJSON), stderr, parse errors, and an exit footer with tool-call and cost totals. Start there when a run ends silently or the card activity log shows only "Still working..." heartbeats.
|
|
115
|
+
|
|
116
|
+
See [Debugging a Silent Run](../../docs/agent-daemon.md#debugging-a-silent-run) for interpretation table and retention notes.
|
|
61
117
|
|
|
62
|
-
|
|
63
|
-
2. Pick up cards from configured columns
|
|
64
|
-
3. Implement changes in isolated git worktrees
|
|
65
|
-
4. Verify builds pass (build + lint)
|
|
66
|
-
5. Push branches and move cards to Review
|
|
67
|
-
6. Review cards and approve (create PR) or reject (move back to To Do)
|
|
118
|
+
See [docs/agent-daemon.md](../../docs/agent-daemon.md) for the full architecture.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { BudgetGuard } from "../budget.js";
|
|
6
|
+
import { StateStore } from "../state-store.js";
|
|
7
|
+
import { DEFAULT_AGENT_CONFIG } from "../types.js";
|
|
8
|
+
describe("BudgetGuard", () => {
|
|
9
|
+
let dir;
|
|
10
|
+
let store;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
dir = mkdtempSync(join(tmpdir(), "budget-"));
|
|
13
|
+
store = new StateStore(join(dir, "state.json"));
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
function makeGuard(overrides = {}) {
|
|
19
|
+
return new BudgetGuard({ ...DEFAULT_AGENT_CONFIG.budget, ...overrides }, store);
|
|
20
|
+
}
|
|
21
|
+
it("allows a fresh card with no history", () => {
|
|
22
|
+
const guard = makeGuard();
|
|
23
|
+
expect(guard.check("c1")).toEqual({ allow: true });
|
|
24
|
+
});
|
|
25
|
+
it("blocks a card already marked DLQ", async () => {
|
|
26
|
+
await store.markDlq("c1", "upstream test");
|
|
27
|
+
const guard = makeGuard();
|
|
28
|
+
const decision = guard.check("c1");
|
|
29
|
+
expect(decision).toMatchObject({ allow: false, reason: "dlq" });
|
|
30
|
+
});
|
|
31
|
+
it("blocks after attempts >= max", async () => {
|
|
32
|
+
await store.incrementAttempt("c1");
|
|
33
|
+
await store.incrementAttempt("c1");
|
|
34
|
+
await store.incrementAttempt("c1");
|
|
35
|
+
const guard = makeGuard({ maxAttemptsPerCard: 3 });
|
|
36
|
+
const decision = guard.check("c1");
|
|
37
|
+
expect(decision).toMatchObject({
|
|
38
|
+
allow: false,
|
|
39
|
+
reason: "max_attempts",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
it("allows again after recordOutcome('success') resets attempts", async () => {
|
|
43
|
+
await store.incrementAttempt("c1");
|
|
44
|
+
await store.incrementAttempt("c1");
|
|
45
|
+
await store.recordOutcome("c1", "success");
|
|
46
|
+
const guard = makeGuard({ maxAttemptsPerCard: 2 });
|
|
47
|
+
expect(guard.check("c1")).toEqual({ allow: true });
|
|
48
|
+
});
|
|
49
|
+
it("blocks on per-card cost cap", async () => {
|
|
50
|
+
await store.addCost("c1", 600);
|
|
51
|
+
const guard = makeGuard({ maxCentsPerCard: 500 });
|
|
52
|
+
expect(guard.check("c1")).toMatchObject({
|
|
53
|
+
allow: false,
|
|
54
|
+
reason: "card_cost_cap",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
it("blocks on daily budget exhaustion", async () => {
|
|
58
|
+
await store.addCost("c1", 5000);
|
|
59
|
+
const guard = makeGuard({ dailyBudgetCents: 5000 });
|
|
60
|
+
// Different card, but daily aggregate already at cap.
|
|
61
|
+
expect(guard.check("c2")).toMatchObject({
|
|
62
|
+
allow: false,
|
|
63
|
+
reason: "daily_budget",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
it("treats DLQ, max_attempts, card_cost_cap as terminal (daily_budget is not)", () => {
|
|
67
|
+
const guard = makeGuard();
|
|
68
|
+
expect(guard.isTerminal("dlq")).toBe(true);
|
|
69
|
+
expect(guard.isTerminal("max_attempts")).toBe(true);
|
|
70
|
+
expect(guard.isTerminal("card_cost_cap")).toBe(true);
|
|
71
|
+
expect(guard.isTerminal("daily_budget")).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
it("markDlq persists state + applies label via runTransition", async () => {
|
|
74
|
+
const guard = makeGuard();
|
|
75
|
+
const client = {
|
|
76
|
+
getBoard: vi.fn().mockResolvedValue({
|
|
77
|
+
columns: [{ id: "col-1", name: "To Do" }],
|
|
78
|
+
labels: [],
|
|
79
|
+
}),
|
|
80
|
+
createLabel: vi.fn().mockResolvedValue({ label: { id: "lbl-dlq" } }),
|
|
81
|
+
addLabelToCard: vi.fn().mockResolvedValue({}),
|
|
82
|
+
};
|
|
83
|
+
const card = {
|
|
84
|
+
id: "c1",
|
|
85
|
+
short_id: 99,
|
|
86
|
+
project_id: "p",
|
|
87
|
+
column_id: "col-1",
|
|
88
|
+
labelIds: [],
|
|
89
|
+
};
|
|
90
|
+
await guard.markDlq(client, card, "max_attempts", "3 attempts");
|
|
91
|
+
expect(store.isDlq("c1")).toBe(true);
|
|
92
|
+
expect(client.addLabelToCard).toHaveBeenCalledWith("c1", "lbl-dlq");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ConfigValidationError, validateColumnReferences, } from "../config-validation.js";
|
|
3
|
+
import { DEFAULT_AGENT_CONFIG } from "../types.js";
|
|
4
|
+
function makeClient(columns) {
|
|
5
|
+
return {
|
|
6
|
+
getBoard: vi.fn().mockResolvedValue({
|
|
7
|
+
columns: columns.map((name, i) => ({ id: `col-${i}`, name })),
|
|
8
|
+
labels: [],
|
|
9
|
+
}),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
describe("validateColumnReferences", () => {
|
|
13
|
+
it("passes when all configured columns exist (case-insensitive)", async () => {
|
|
14
|
+
const client = makeClient([
|
|
15
|
+
"to do",
|
|
16
|
+
"In Progress",
|
|
17
|
+
"REVIEW",
|
|
18
|
+
"Done",
|
|
19
|
+
"Needs Fix",
|
|
20
|
+
]);
|
|
21
|
+
await expect(validateColumnReferences(client, "proj", DEFAULT_AGENT_CONFIG)).resolves.toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
it("throws a typed error listing every missing column", async () => {
|
|
24
|
+
const client = makeClient(["To Do", "Review", "Done"]);
|
|
25
|
+
const config = {
|
|
26
|
+
...DEFAULT_AGENT_CONFIG,
|
|
27
|
+
verification: {
|
|
28
|
+
...DEFAULT_AGENT_CONFIG.verification,
|
|
29
|
+
failColumn: "Needs Fix",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
await validateColumnReferences(client, "proj", config);
|
|
34
|
+
throw new Error("expected rejection");
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
expect(err).toBeInstanceOf(ConfigValidationError);
|
|
38
|
+
const issues = err.issues;
|
|
39
|
+
expect(issues.some((i) => i.includes("Needs Fix"))).toBe(true);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
it("skips review column checks when review is disabled", async () => {
|
|
43
|
+
const client = makeClient(["To Do", "Review", "Needs Fix"]);
|
|
44
|
+
const config = {
|
|
45
|
+
...DEFAULT_AGENT_CONFIG,
|
|
46
|
+
completion: {
|
|
47
|
+
...DEFAULT_AGENT_CONFIG.completion,
|
|
48
|
+
moveToColumn: "Review",
|
|
49
|
+
},
|
|
50
|
+
review: { ...DEFAULT_AGENT_CONFIG.review, enabled: false },
|
|
51
|
+
};
|
|
52
|
+
await expect(validateColumnReferences(client, "proj", config)).resolves.toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
it("reports the missing column along with the config path", async () => {
|
|
55
|
+
const client = makeClient(["To Do", "In Progress", "Review", "Done"]);
|
|
56
|
+
const config = {
|
|
57
|
+
...DEFAULT_AGENT_CONFIG,
|
|
58
|
+
verification: {
|
|
59
|
+
...DEFAULT_AGENT_CONFIG.verification,
|
|
60
|
+
failColumn: "DOES NOT EXIST",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
await expect(validateColumnReferences(client, "proj", config)).rejects.toThrow(/verification\.failColumn: column "DOES NOT EXIST"/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { DevServerReadinessError, waitForDevServer } from "../verification.js";
|
|
4
|
+
describe("waitForDevServer", () => {
|
|
5
|
+
it("resolves when readiness signal appears on stdout", async () => {
|
|
6
|
+
// node -e "console.log('server ready on localhost:4200'); setTimeout(()=>{}, 1000)"
|
|
7
|
+
const proc = spawn(process.execPath, [
|
|
8
|
+
"-e",
|
|
9
|
+
"console.log('server ready on localhost:4200'); setTimeout(() => {}, 1000);",
|
|
10
|
+
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
11
|
+
await expect(waitForDevServer(proc, 2000)).resolves.toBeUndefined();
|
|
12
|
+
proc.kill("SIGKILL");
|
|
13
|
+
});
|
|
14
|
+
it("rejects (does NOT resolve) on timeout", async () => {
|
|
15
|
+
// Silent child; no readiness signal.
|
|
16
|
+
const proc = spawn(process.execPath, ["-e", "setTimeout(() => {}, 5000);"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
17
|
+
await expect(waitForDevServer(proc, 100)).rejects.toBeInstanceOf(DevServerReadinessError);
|
|
18
|
+
proc.kill("SIGKILL");
|
|
19
|
+
});
|
|
20
|
+
it("rejects if the process exits before becoming ready", async () => {
|
|
21
|
+
const proc = spawn(process.execPath, ["-e", "process.exit(7);"], {
|
|
22
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
23
|
+
});
|
|
24
|
+
await expect(waitForDevServer(proc, 5000)).rejects.toBeInstanceOf(DevServerReadinessError);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { HttpServer, } from "../http-server.js";
|
|
3
|
+
function baseStatus() {
|
|
4
|
+
return {
|
|
5
|
+
daemonId: "d-1",
|
|
6
|
+
daemonPid: 1234,
|
|
7
|
+
startedAt: Date.now(),
|
|
8
|
+
uptimeMs: 0,
|
|
9
|
+
workers: [],
|
|
10
|
+
implQueue: [],
|
|
11
|
+
reviewQueue: [],
|
|
12
|
+
dlq: [],
|
|
13
|
+
budget: { todayCents: 0, dailyCapCents: 5000 },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe("HttpServer", () => {
|
|
17
|
+
let server;
|
|
18
|
+
let port;
|
|
19
|
+
let getHealth;
|
|
20
|
+
let handleCommand;
|
|
21
|
+
let clearDlq;
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
// Ephemeral port via 0 doesn't round-trip through the options — use
|
|
24
|
+
// a random high port and retry on collision (rare in CI).
|
|
25
|
+
port = 40000 + Math.floor(Math.random() * 10000);
|
|
26
|
+
getHealth = () => ({ healthy: true, checks: { watcher: true } });
|
|
27
|
+
handleCommand = vi.fn().mockResolvedValue(undefined);
|
|
28
|
+
clearDlq = vi.fn().mockResolvedValue(undefined);
|
|
29
|
+
server = new HttpServer({
|
|
30
|
+
port,
|
|
31
|
+
bindAddr: "127.0.0.1",
|
|
32
|
+
getHealth: () => getHealth(),
|
|
33
|
+
getStatus: () => baseStatus(),
|
|
34
|
+
handleCommand: (c, id) => handleCommand(c, id),
|
|
35
|
+
clearDlq: (id) => clearDlq(id),
|
|
36
|
+
});
|
|
37
|
+
await server.start();
|
|
38
|
+
});
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await server.stop();
|
|
41
|
+
});
|
|
42
|
+
async function get(path) {
|
|
43
|
+
return fetch(`http://127.0.0.1:${port}${path}`);
|
|
44
|
+
}
|
|
45
|
+
async function post(path) {
|
|
46
|
+
return fetch(`http://127.0.0.1:${port}${path}`, { method: "POST" });
|
|
47
|
+
}
|
|
48
|
+
it("GET /health returns 200 when healthy", async () => {
|
|
49
|
+
const res = await get("/health");
|
|
50
|
+
expect(res.status).toBe(200);
|
|
51
|
+
const body = await res.json();
|
|
52
|
+
expect(body.healthy).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it("GET /health returns 503 when unhealthy", async () => {
|
|
55
|
+
getHealth = () => ({
|
|
56
|
+
healthy: false,
|
|
57
|
+
checks: { watcher: false, reconciler: true },
|
|
58
|
+
});
|
|
59
|
+
const res = await get("/health");
|
|
60
|
+
expect(res.status).toBe(503);
|
|
61
|
+
const body = await res.json();
|
|
62
|
+
expect(body.checks.watcher).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
it("GET /status returns the snapshot shape", async () => {
|
|
65
|
+
const res = await get("/status");
|
|
66
|
+
expect(res.status).toBe(200);
|
|
67
|
+
const body = await res.json();
|
|
68
|
+
expect(body).toHaveProperty("daemonId", "d-1");
|
|
69
|
+
expect(body).toHaveProperty("workers");
|
|
70
|
+
expect(body).toHaveProperty("implQueue");
|
|
71
|
+
expect(body).toHaveProperty("dlq");
|
|
72
|
+
expect(body).toHaveProperty("budget");
|
|
73
|
+
});
|
|
74
|
+
it("POST /pause/:cardId dispatches to handleCommand", async () => {
|
|
75
|
+
const res = await post("/pause/abc-123");
|
|
76
|
+
expect(res.status).toBe(200);
|
|
77
|
+
expect(handleCommand).toHaveBeenCalledWith("pause", "abc-123");
|
|
78
|
+
});
|
|
79
|
+
it("POST /resume/:cardId dispatches correctly", async () => {
|
|
80
|
+
const res = await post("/resume/xyz");
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
expect(handleCommand).toHaveBeenCalledWith("resume", "xyz");
|
|
83
|
+
});
|
|
84
|
+
it("POST /stop/:cardId dispatches correctly", async () => {
|
|
85
|
+
const res = await post("/stop/c1");
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
expect(handleCommand).toHaveBeenCalledWith("stop", "c1");
|
|
88
|
+
});
|
|
89
|
+
it("returns 404 for unknown paths", async () => {
|
|
90
|
+
const res = await get("/does-not-exist");
|
|
91
|
+
expect(res.status).toBe(404);
|
|
92
|
+
});
|
|
93
|
+
it("POST /dlq/clear/:cardId routes through the daemon", async () => {
|
|
94
|
+
const res = await post("/dlq/clear/card-42");
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
expect(clearDlq).toHaveBeenCalledWith("card-42");
|
|
97
|
+
const body = await res.json();
|
|
98
|
+
expect(body.ok).toBe(true);
|
|
99
|
+
expect(body.cardId).toBe("card-42");
|
|
100
|
+
});
|
|
101
|
+
it("POST /dlq/clear returns 500 with detail on failure", async () => {
|
|
102
|
+
clearDlq = vi.fn().mockRejectedValue(new Error("state store locked"));
|
|
103
|
+
const res = await post("/dlq/clear/card-99");
|
|
104
|
+
expect(res.status).toBe(500);
|
|
105
|
+
const body = await res.json();
|
|
106
|
+
expect(body.detail).toContain("state store locked");
|
|
107
|
+
});
|
|
108
|
+
it("returns 500 with detail when handleCommand throws", async () => {
|
|
109
|
+
handleCommand = vi.fn().mockRejectedValue(new Error("worker gone"));
|
|
110
|
+
const res = await post("/stop/c1");
|
|
111
|
+
expect(res.status).toBe(500);
|
|
112
|
+
const body = await res.json();
|
|
113
|
+
expect(body.detail).toContain("worker gone");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
describe("log", () => {
|
|
3
|
+
let writes;
|
|
4
|
+
let origWrite;
|
|
5
|
+
let origArgv;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
writes = [];
|
|
8
|
+
origWrite = process.stderr.write;
|
|
9
|
+
origArgv = process.argv;
|
|
10
|
+
process.stderr.write = ((chunk) => {
|
|
11
|
+
writes.push(chunk);
|
|
12
|
+
return true;
|
|
13
|
+
});
|
|
14
|
+
delete process.env.HARMONY_AGENT_PRETTY;
|
|
15
|
+
delete process.env.HARMONY_AGENT_JSON;
|
|
16
|
+
delete process.env.DEBUG;
|
|
17
|
+
vi.resetModules();
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.stderr.write = origWrite;
|
|
21
|
+
process.argv = origArgv;
|
|
22
|
+
});
|
|
23
|
+
it("emits JSON lines when stderr is not a TTY", async () => {
|
|
24
|
+
// Vitest stderr is not a TTY by default; no need to force.
|
|
25
|
+
const { log } = await import("../log.js");
|
|
26
|
+
log.info("worker", "hello", { cardId: "c1", runId: "r1" });
|
|
27
|
+
const parsed = JSON.parse(writes[0]);
|
|
28
|
+
expect(parsed).toMatchObject({
|
|
29
|
+
level: "info",
|
|
30
|
+
tag: "worker",
|
|
31
|
+
msg: "hello",
|
|
32
|
+
cardId: "c1",
|
|
33
|
+
runId: "r1",
|
|
34
|
+
});
|
|
35
|
+
expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
36
|
+
});
|
|
37
|
+
it("emits pretty output when stderr is a TTY", async () => {
|
|
38
|
+
const originalIsTTY = process.stderr.isTTY;
|
|
39
|
+
Object.defineProperty(process.stderr, "isTTY", {
|
|
40
|
+
value: true,
|
|
41
|
+
configurable: true,
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
const { log } = await import("../log.js");
|
|
45
|
+
log.info("daemon", "up");
|
|
46
|
+
expect(() => JSON.parse(writes[0])).toThrow();
|
|
47
|
+
expect(writes[0]).toContain("INFO");
|
|
48
|
+
expect(writes[0]).toContain("[daemon]");
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
Object.defineProperty(process.stderr, "isTTY", {
|
|
52
|
+
value: originalIsTTY,
|
|
53
|
+
configurable: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
it("--json forces JSON even on a TTY", async () => {
|
|
58
|
+
const originalIsTTY = process.stderr.isTTY;
|
|
59
|
+
Object.defineProperty(process.stderr, "isTTY", {
|
|
60
|
+
value: true,
|
|
61
|
+
configurable: true,
|
|
62
|
+
});
|
|
63
|
+
process.argv = [...process.argv, "--json"];
|
|
64
|
+
try {
|
|
65
|
+
const { log } = await import("../log.js");
|
|
66
|
+
log.info("daemon", "up");
|
|
67
|
+
const parsed = JSON.parse(writes[0]);
|
|
68
|
+
expect(parsed.msg).toBe("up");
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
Object.defineProperty(process.stderr, "isTTY", {
|
|
72
|
+
value: originalIsTTY,
|
|
73
|
+
configurable: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it("HARMONY_AGENT_JSON=1 beats HARMONY_AGENT_PRETTY=1", async () => {
|
|
78
|
+
process.env.HARMONY_AGENT_PRETTY = "1";
|
|
79
|
+
process.env.HARMONY_AGENT_JSON = "1";
|
|
80
|
+
const { log } = await import("../log.js");
|
|
81
|
+
log.info("daemon", "hi");
|
|
82
|
+
const parsed = JSON.parse(writes[0]);
|
|
83
|
+
expect(parsed.msg).toBe("hi");
|
|
84
|
+
});
|
|
85
|
+
it("suppresses debug when DEBUG is unset", async () => {
|
|
86
|
+
const { log } = await import("../log.js");
|
|
87
|
+
log.debug("x", "quiet");
|
|
88
|
+
expect(writes).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
it("emits debug when DEBUG is set", async () => {
|
|
91
|
+
process.env.DEBUG = "1";
|
|
92
|
+
const { log } = await import("../log.js");
|
|
93
|
+
log.debug("x", "loud");
|
|
94
|
+
expect(writes).toHaveLength(1);
|
|
95
|
+
const parsed = JSON.parse(writes[0]);
|
|
96
|
+
expect(parsed.level).toBe("debug");
|
|
97
|
+
});
|
|
98
|
+
it("log.event attaches the event field", async () => {
|
|
99
|
+
const { log } = await import("../log.js");
|
|
100
|
+
log.event("pool", "card_enqueued", { cardId: "c2" });
|
|
101
|
+
const parsed = JSON.parse(writes[0]);
|
|
102
|
+
expect(parsed.event).toBe("card_enqueued");
|
|
103
|
+
expect(parsed.msg).toBe("card_enqueued");
|
|
104
|
+
expect(parsed.cardId).toBe("c2");
|
|
105
|
+
});
|
|
106
|
+
it("switches to ANSI pretty output under HARMONY_AGENT_PRETTY=1", async () => {
|
|
107
|
+
process.env.HARMONY_AGENT_PRETTY = "1";
|
|
108
|
+
const { log } = await import("../log.js");
|
|
109
|
+
log.warn("daemon", "heads up");
|
|
110
|
+
// Pretty output is not JSON — should fail parse and contain WARN label
|
|
111
|
+
expect(() => JSON.parse(writes[0])).toThrow();
|
|
112
|
+
expect(writes[0]).toContain("WARN");
|
|
113
|
+
expect(writes[0]).toContain("[daemon]");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { signalGroup, spawnInGroup, terminateGroup } from "../process-group.js";
|
|
3
|
+
describe("process-group", () => {
|
|
4
|
+
it("places the child in its own process group (pid === pgid)", async () => {
|
|
5
|
+
if (process.platform === "win32")
|
|
6
|
+
return;
|
|
7
|
+
const proc = spawnInGroup("sh", ["-c", "ps -o pgid= -p $$ | tr -d ' '"]);
|
|
8
|
+
let stdout = "";
|
|
9
|
+
proc.stdout?.on("data", (d) => {
|
|
10
|
+
stdout += d.toString();
|
|
11
|
+
});
|
|
12
|
+
await new Promise((resolve) => {
|
|
13
|
+
proc.on("exit", () => resolve());
|
|
14
|
+
});
|
|
15
|
+
const pgid = parseInt(stdout.trim(), 10);
|
|
16
|
+
expect(pgid).toBe(proc.pid);
|
|
17
|
+
});
|
|
18
|
+
it("signalGroup handles already-dead processes silently", () => {
|
|
19
|
+
const fakeProc = {
|
|
20
|
+
pid: 999_999_999,
|
|
21
|
+
killed: false,
|
|
22
|
+
kill: () => true,
|
|
23
|
+
};
|
|
24
|
+
expect(() => signalGroup(fakeProc, "SIGTERM")).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
it("terminateGroup exits a cooperative child on SIGINT", async () => {
|
|
27
|
+
if (process.platform === "win32")
|
|
28
|
+
return;
|
|
29
|
+
const proc = spawnInGroup(process.execPath, [
|
|
30
|
+
"-e",
|
|
31
|
+
"process.on('SIGINT', () => process.exit(0)); setInterval(()=>{}, 1000);",
|
|
32
|
+
]);
|
|
33
|
+
// Capture the exit state up front so we never miss it.
|
|
34
|
+
const exited = new Promise((resolve) => {
|
|
35
|
+
proc.once("exit", (code, signal) => resolve({ code, signal }));
|
|
36
|
+
});
|
|
37
|
+
// Give the child enough time to attach its SIGINT handler even
|
|
38
|
+
// under contention (9 parallel test files, git spawning, etc.).
|
|
39
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
40
|
+
await terminateGroup(proc, {
|
|
41
|
+
sigintTimeoutMs: 2000,
|
|
42
|
+
sigtermTimeoutMs: 500,
|
|
43
|
+
});
|
|
44
|
+
const result = await exited;
|
|
45
|
+
// Either the handler fired (code=0) or we escalated past it — in
|
|
46
|
+
// both cases the termination contract is kept. We really only want
|
|
47
|
+
// to prove the process group was reached.
|
|
48
|
+
expect(result.code !== null || result.signal !== null).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it("terminateGroup escalates to SIGKILL when the group ignores signals", async () => {
|
|
51
|
+
if (process.platform === "win32")
|
|
52
|
+
return;
|
|
53
|
+
const proc = spawnInGroup(process.execPath, [
|
|
54
|
+
"-e",
|
|
55
|
+
"process.on('SIGINT', () => {}); process.on('SIGTERM', () => {}); setInterval(()=>{}, 1000);",
|
|
56
|
+
]);
|
|
57
|
+
const exited = new Promise((resolve) => {
|
|
58
|
+
proc.once("exit", (code, signal) => resolve({ code, signal }));
|
|
59
|
+
});
|
|
60
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
61
|
+
await terminateGroup(proc, {
|
|
62
|
+
sigintTimeoutMs: 200,
|
|
63
|
+
sigtermTimeoutMs: 200,
|
|
64
|
+
});
|
|
65
|
+
const result = await exited;
|
|
66
|
+
expect(result.signal === "SIGKILL" || result.code !== null).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|