@gethmy/agent 1.0.9 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +67 -16
  2. package/dist/__tests__/budget.test.d.ts +1 -0
  3. package/dist/__tests__/budget.test.js +94 -0
  4. package/dist/__tests__/config-validation.test.d.ts +1 -0
  5. package/dist/__tests__/config-validation.test.js +65 -0
  6. package/dist/__tests__/dev-server-readiness.test.d.ts +1 -0
  7. package/dist/__tests__/dev-server-readiness.test.js +26 -0
  8. package/dist/__tests__/http-server.test.d.ts +1 -0
  9. package/dist/__tests__/http-server.test.js +115 -0
  10. package/dist/__tests__/log.test.d.ts +1 -0
  11. package/dist/__tests__/log.test.js +115 -0
  12. package/dist/__tests__/process-group.test.d.ts +1 -0
  13. package/dist/__tests__/process-group.test.js +68 -0
  14. package/dist/__tests__/reconcile-heartbeat.test.d.ts +1 -0
  15. package/dist/__tests__/reconcile-heartbeat.test.js +116 -0
  16. package/dist/__tests__/recovery.test.d.ts +1 -0
  17. package/dist/__tests__/recovery.test.js +126 -0
  18. package/dist/__tests__/review-parser.test.d.ts +1 -0
  19. package/dist/__tests__/review-parser.test.js +65 -0
  20. package/dist/__tests__/state-store.test.d.ts +1 -0
  21. package/dist/__tests__/state-store.test.js +132 -0
  22. package/dist/__tests__/transitions.test.d.ts +1 -0
  23. package/dist/__tests__/transitions.test.js +130 -0
  24. package/dist/__tests__/worktree-gc.test.d.ts +1 -0
  25. package/dist/__tests__/worktree-gc.test.js +137 -0
  26. package/dist/budget.d.ts +45 -0
  27. package/dist/budget.js +94 -0
  28. package/dist/cli.d.ts +15 -1
  29. package/dist/cli.js +239 -1
  30. package/dist/completion.d.ts +9 -0
  31. package/dist/completion.js +28 -2
  32. package/dist/config-validation.d.ts +18 -0
  33. package/dist/config-validation.js +66 -0
  34. package/dist/config.js +12 -0
  35. package/dist/http-server.d.ts +79 -0
  36. package/dist/http-server.js +115 -0
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.js +125 -10
  39. package/dist/log.d.ts +29 -5
  40. package/dist/log.js +80 -15
  41. package/dist/pool.d.ts +27 -2
  42. package/dist/pool.js +69 -4
  43. package/dist/process-group.d.ts +26 -0
  44. package/dist/process-group.js +72 -0
  45. package/dist/progress-tracker.js +2 -0
  46. package/dist/queue.d.ts +2 -0
  47. package/dist/queue.js +4 -0
  48. package/dist/reconcile.d.ts +15 -1
  49. package/dist/reconcile.js +63 -2
  50. package/dist/recovery.d.ts +30 -0
  51. package/dist/recovery.js +136 -0
  52. package/dist/review-completion.d.ts +12 -4
  53. package/dist/review-completion.js +158 -49
  54. package/dist/review-worker.d.ts +9 -2
  55. package/dist/review-worker.js +182 -78
  56. package/dist/run-log.d.ts +6 -0
  57. package/dist/run-log.js +19 -0
  58. package/dist/state-store.d.ts +72 -0
  59. package/dist/state-store.js +216 -0
  60. package/dist/transitions.d.ts +57 -0
  61. package/dist/transitions.js +131 -0
  62. package/dist/types.d.ts +23 -0
  63. package/dist/types.js +19 -1
  64. package/dist/verification.d.ts +17 -0
  65. package/dist/verification.js +71 -10
  66. package/dist/watcher.d.ts +2 -0
  67. package/dist/watcher.js +11 -0
  68. package/dist/worker.d.ts +9 -2
  69. package/dist/worker.js +168 -47
  70. package/dist/worktree-gc.d.ts +39 -0
  71. package/dist/worktree-gc.js +139 -0
  72. package/package.json +2 -2
package/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
- ```json
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
- ## Usage
53
-
54
- Run from your git repository root:
69
+ ## CLI
55
70
 
56
71
  ```bash
57
- npx @gethmy/agent
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
- The daemon will:
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
- 1. Watch for cards assigned to your agent user
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 {};