@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
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
});
|
package/dist/budget.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
+
import type { Card } from "@harmony/shared";
|
|
3
|
+
import type { StateStore } from "./state-store.js";
|
|
4
|
+
import type { AgentConfig } from "./types.js";
|
|
5
|
+
export type GuardDecision = {
|
|
6
|
+
allow: true;
|
|
7
|
+
} | {
|
|
8
|
+
allow: false;
|
|
9
|
+
reason: GuardReason;
|
|
10
|
+
detail: string;
|
|
11
|
+
};
|
|
12
|
+
export type GuardReason = "dlq" | "max_attempts" | "card_cost_cap" | "daily_budget";
|
|
13
|
+
/**
|
|
14
|
+
* BudgetGuard is consulted on every pickup and on every run start.
|
|
15
|
+
* It protects the daemon from three failure modes:
|
|
16
|
+
* 1. Cards that can never succeed (DLQ after N failed attempts).
|
|
17
|
+
* 2. Cards that burn unbounded tokens on a single attempt.
|
|
18
|
+
* 3. Runaway daily spend across the entire daemon.
|
|
19
|
+
*
|
|
20
|
+
* The guard is advisory for the hot path (returns a decision); the
|
|
21
|
+
* caller is responsible for marking DLQ and skipping the enqueue.
|
|
22
|
+
*/
|
|
23
|
+
export declare class BudgetGuard {
|
|
24
|
+
private config;
|
|
25
|
+
private store;
|
|
26
|
+
constructor(config: AgentConfig["budget"], store: StateStore);
|
|
27
|
+
/**
|
|
28
|
+
* Inspect a card before we commit to picking it up. If any threshold
|
|
29
|
+
* is already exceeded, return a skip decision — the caller should
|
|
30
|
+
* apply the DLQ label (for `dlq`/`max_attempts`/`card_cost_cap`) or
|
|
31
|
+
* simply hold until the daily budget resets (`daily_budget`).
|
|
32
|
+
*/
|
|
33
|
+
check(cardId: string): GuardDecision;
|
|
34
|
+
/**
|
|
35
|
+
* Does the guard's decision warrant a permanent DLQ marker? The daily
|
|
36
|
+
* budget is *not* permanent — it resets at UTC midnight — so we only
|
|
37
|
+
* DLQ for terminal states.
|
|
38
|
+
*/
|
|
39
|
+
isTerminal(reason: GuardReason): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Apply the DLQ label to a card and post a comment explaining why.
|
|
42
|
+
* Safe to call repeatedly — labels are idempotent.
|
|
43
|
+
*/
|
|
44
|
+
markDlq(client: HarmonyApiClient, card: Card, reason: GuardReason, detail: string): Promise<void>;
|
|
45
|
+
}
|
package/dist/budget.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { log } from "./log.js";
|
|
2
|
+
import { runTransition } from "./transitions.js";
|
|
3
|
+
const TAG = "budget";
|
|
4
|
+
/**
|
|
5
|
+
* BudgetGuard is consulted on every pickup and on every run start.
|
|
6
|
+
* It protects the daemon from three failure modes:
|
|
7
|
+
* 1. Cards that can never succeed (DLQ after N failed attempts).
|
|
8
|
+
* 2. Cards that burn unbounded tokens on a single attempt.
|
|
9
|
+
* 3. Runaway daily spend across the entire daemon.
|
|
10
|
+
*
|
|
11
|
+
* The guard is advisory for the hot path (returns a decision); the
|
|
12
|
+
* caller is responsible for marking DLQ and skipping the enqueue.
|
|
13
|
+
*/
|
|
14
|
+
export class BudgetGuard {
|
|
15
|
+
config;
|
|
16
|
+
store;
|
|
17
|
+
constructor(config, store) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.store = store;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Inspect a card before we commit to picking it up. If any threshold
|
|
23
|
+
* is already exceeded, return a skip decision — the caller should
|
|
24
|
+
* apply the DLQ label (for `dlq`/`max_attempts`/`card_cost_cap`) or
|
|
25
|
+
* simply hold until the daily budget resets (`daily_budget`).
|
|
26
|
+
*/
|
|
27
|
+
check(cardId) {
|
|
28
|
+
if (this.store.isDlq(cardId)) {
|
|
29
|
+
const rec = this.store.getCard(cardId);
|
|
30
|
+
return {
|
|
31
|
+
allow: false,
|
|
32
|
+
reason: "dlq",
|
|
33
|
+
detail: rec?.dlqReason ?? "previously marked DLQ",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const card = this.store.getCard(cardId);
|
|
37
|
+
if (card) {
|
|
38
|
+
if (card.attempts >= this.config.maxAttemptsPerCard) {
|
|
39
|
+
return {
|
|
40
|
+
allow: false,
|
|
41
|
+
reason: "max_attempts",
|
|
42
|
+
detail: `${card.attempts} of ${this.config.maxAttemptsPerCard} attempts exhausted`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (card.totalCostCents >= this.config.maxCentsPerCard) {
|
|
46
|
+
return {
|
|
47
|
+
allow: false,
|
|
48
|
+
reason: "card_cost_cap",
|
|
49
|
+
detail: `spent ${formatCents(card.totalCostCents)} of ${formatCents(this.config.maxCentsPerCard)} per-card cap`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const dailySpent = this.store.getDailyCostCents();
|
|
54
|
+
if (dailySpent >= this.config.dailyBudgetCents) {
|
|
55
|
+
return {
|
|
56
|
+
allow: false,
|
|
57
|
+
reason: "daily_budget",
|
|
58
|
+
detail: `daily budget ${formatCents(dailySpent)}/${formatCents(this.config.dailyBudgetCents)} exhausted`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { allow: true };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Does the guard's decision warrant a permanent DLQ marker? The daily
|
|
65
|
+
* budget is *not* permanent — it resets at UTC midnight — so we only
|
|
66
|
+
* DLQ for terminal states.
|
|
67
|
+
*/
|
|
68
|
+
isTerminal(reason) {
|
|
69
|
+
return (reason === "dlq" ||
|
|
70
|
+
reason === "max_attempts" ||
|
|
71
|
+
reason === "card_cost_cap");
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Apply the DLQ label to a card and post a comment explaining why.
|
|
75
|
+
* Safe to call repeatedly — labels are idempotent.
|
|
76
|
+
*/
|
|
77
|
+
async markDlq(client, card, reason, detail) {
|
|
78
|
+
await this.store.markDlq(card.id, `${reason}: ${detail}`);
|
|
79
|
+
try {
|
|
80
|
+
await runTransition(client, card, {
|
|
81
|
+
addLabels: [
|
|
82
|
+
{ name: this.config.dlqLabel, color: this.config.dlqLabelColor },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
log.warn(TAG, `failed to add dlq label to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
|
|
88
|
+
}
|
|
89
|
+
log.warn(TAG, `#${card.short_id} DLQ'd — ${reason}: ${detail}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function formatCents(cents) {
|
|
93
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
94
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Harmony Agent CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Subcommands:
|
|
6
|
+
* (no args) — run the daemon (same as `run`)
|
|
7
|
+
* run — run the daemon
|
|
8
|
+
* status — GET /status from the running daemon, pretty-print
|
|
9
|
+
* health — GET /health, exit 0 if healthy, 1 otherwise
|
|
10
|
+
* doctor — run preflight checks without starting the daemon
|
|
11
|
+
* gc — one-shot worktree garbage collection
|
|
12
|
+
* dlq list — print DLQ entries
|
|
13
|
+
* dlq clear <id> — clear a card's DLQ mark
|
|
14
|
+
* help — show usage
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,2 +1,240 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Harmony Agent CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Subcommands:
|
|
6
|
+
* (no args) — run the daemon (same as `run`)
|
|
7
|
+
* run — run the daemon
|
|
8
|
+
* status — GET /status from the running daemon, pretty-print
|
|
9
|
+
* health — GET /health, exit 0 if healthy, 1 otherwise
|
|
10
|
+
* doctor — run preflight checks without starting the daemon
|
|
11
|
+
* gc — one-shot worktree garbage collection
|
|
12
|
+
* dlq list — print DLQ entries
|
|
13
|
+
* dlq clear <id> — clear a card's DLQ mark
|
|
14
|
+
* help — show usage
|
|
15
|
+
*/
|
|
16
|
+
import { log } from "./log.js";
|
|
17
|
+
const USAGE = `
|
|
18
|
+
Harmony Agent — push-based daemon + ops toolkit.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
harmony-agent [run] Start the daemon (default)
|
|
22
|
+
harmony-agent status Fetch status from the running daemon
|
|
23
|
+
harmony-agent health Exit 0 if daemon is healthy, 1 otherwise
|
|
24
|
+
harmony-agent doctor Run preflight checks (don't start)
|
|
25
|
+
harmony-agent gc One-shot worktree garbage collection
|
|
26
|
+
harmony-agent dlq list List dead-lettered cards
|
|
27
|
+
harmony-agent dlq clear <cardId> Clear a card's DLQ marker
|
|
28
|
+
harmony-agent help Show this help
|
|
29
|
+
|
|
30
|
+
Flags:
|
|
31
|
+
--pretty Force colored human log output
|
|
32
|
+
(also: HARMONY_AGENT_PRETTY=1)
|
|
33
|
+
--json Force structured JSON log output
|
|
34
|
+
(also: HARMONY_AGENT_JSON=1)
|
|
35
|
+
|
|
36
|
+
By default logs are pretty when stderr is a TTY and JSON when piped.
|
|
37
|
+
`.trim();
|
|
38
|
+
async function runDaemon() {
|
|
39
|
+
const { main } = await import("./index.js");
|
|
40
|
+
await main();
|
|
41
|
+
}
|
|
42
|
+
async function httpCall(path, init) {
|
|
43
|
+
const { loadDaemonConfig } = await import("./config.js");
|
|
44
|
+
const cfg = loadDaemonConfig();
|
|
45
|
+
const url = `http://${cfg.agent.http.bindAddr}:${cfg.agent.http.port}${path}`;
|
|
46
|
+
return fetch(url, init);
|
|
47
|
+
}
|
|
48
|
+
/** True if the error looks like "daemon is not running" (ECONNREFUSED). */
|
|
49
|
+
function isDaemonDown(err) {
|
|
50
|
+
const code = err?.cause?.code;
|
|
51
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
52
|
+
return (code === "ECONNREFUSED" || /ECONNREFUSED|fetch failed|connect/i.test(msg));
|
|
53
|
+
}
|
|
54
|
+
function printStatus(body) {
|
|
55
|
+
const out = process.stdout;
|
|
56
|
+
const uptime = formatDuration(body.uptimeMs);
|
|
57
|
+
out.write(`daemon ${body.daemonId} (pid ${body.daemonPid}, up ${uptime})\n`);
|
|
58
|
+
out.write(`budget $${(body.budget.todayCents / 100).toFixed(2)} / $${(body.budget.dailyCapCents / 100).toFixed(2)} today\n`);
|
|
59
|
+
out.write(`workers (${body.workers.length})\n`);
|
|
60
|
+
for (const w of body.workers) {
|
|
61
|
+
const card = w.cardId ? ` card=${w.cardId}` : "";
|
|
62
|
+
const br = w.branchName ? ` branch=${w.branchName}` : "";
|
|
63
|
+
out.write(` #${w.id} ${w.pipeline.padEnd(9)} ${w.state}${card}${br}\n`);
|
|
64
|
+
}
|
|
65
|
+
out.write(`impl queue (${body.implQueue.length})\n`);
|
|
66
|
+
for (const q of body.implQueue) {
|
|
67
|
+
out.write(` #${q.shortId} priority=${q.priority}\n`);
|
|
68
|
+
}
|
|
69
|
+
out.write(`review queue (${body.reviewQueue.length})\n`);
|
|
70
|
+
for (const q of body.reviewQueue) {
|
|
71
|
+
out.write(` #${q.shortId} priority=${q.priority}\n`);
|
|
72
|
+
}
|
|
73
|
+
out.write(`dlq (${body.dlq.length})\n`);
|
|
74
|
+
for (const d of body.dlq) {
|
|
75
|
+
out.write(` ${d.cardId} attempts=${d.attempts} cost=$${(d.totalCostCents / 100).toFixed(2)} reason=${d.reason}\n`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function formatDuration(ms) {
|
|
79
|
+
const s = Math.floor(ms / 1000);
|
|
80
|
+
const h = Math.floor(s / 3600);
|
|
81
|
+
const m = Math.floor((s % 3600) / 60);
|
|
82
|
+
const sec = s % 60;
|
|
83
|
+
if (h > 0)
|
|
84
|
+
return `${h}h${m}m`;
|
|
85
|
+
if (m > 0)
|
|
86
|
+
return `${m}m${sec}s`;
|
|
87
|
+
return `${sec}s`;
|
|
88
|
+
}
|
|
89
|
+
async function statusCommand() {
|
|
90
|
+
try {
|
|
91
|
+
const res = await httpCall("/status");
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
process.stdout.write(`daemon responded ${res.status} ${res.statusText}\n`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
const body = (await res.json());
|
|
97
|
+
printStatus(body);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
process.stderr.write(`could not reach daemon: ${err instanceof Error ? err.message : err}\n`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function healthCommand() {
|
|
106
|
+
try {
|
|
107
|
+
const res = await httpCall("/health");
|
|
108
|
+
const body = (await res.json());
|
|
109
|
+
process.stdout.write(`${JSON.stringify(body, null, 2)}\n`);
|
|
110
|
+
return body.healthy ? 0 : 1;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
process.stderr.write(`could not reach daemon: ${err instanceof Error ? err.message : err}\n`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function doctorCommand() {
|
|
118
|
+
const { loadDaemonConfig } = await import("./config.js");
|
|
119
|
+
const { validatePrerequisites } = await import("./index.js");
|
|
120
|
+
try {
|
|
121
|
+
const config = loadDaemonConfig();
|
|
122
|
+
const userId = await validatePrerequisites(config);
|
|
123
|
+
process.stdout.write(`all checks passed — agent user: ${userId}\n`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
process.stderr.write(`preflight failed: ${err instanceof Error ? err.message : err}\n`);
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function gcCommand() {
|
|
132
|
+
const { loadDaemonConfig } = await import("./config.js");
|
|
133
|
+
const { runWorktreeGc } = await import("./worktree-gc.js");
|
|
134
|
+
const { StateStore } = await import("./state-store.js");
|
|
135
|
+
const config = loadDaemonConfig();
|
|
136
|
+
const store = StateStore.open();
|
|
137
|
+
// For a manual gc, minAge=0 — operator is choosing to sweep now.
|
|
138
|
+
const result = runWorktreeGc(config.agent.worktree.basePath, store, {
|
|
139
|
+
minAgeMs: 0,
|
|
140
|
+
});
|
|
141
|
+
process.stdout.write(`checked: ${result.checked}, removed: ${result.removed.length}, skipped: ${result.skipped.length}, errors: ${result.errors.length}\n`);
|
|
142
|
+
if (result.removed.length) {
|
|
143
|
+
for (const p of result.removed)
|
|
144
|
+
process.stdout.write(` removed ${p}\n`);
|
|
145
|
+
}
|
|
146
|
+
if (result.errors.length) {
|
|
147
|
+
for (const e of result.errors) {
|
|
148
|
+
process.stderr.write(` error ${e.path}: ${e.error}\n`);
|
|
149
|
+
}
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
async function dlqCommand(args) {
|
|
155
|
+
const sub = args[0];
|
|
156
|
+
const { StateStore } = await import("./state-store.js");
|
|
157
|
+
const store = StateStore.open();
|
|
158
|
+
if (!sub || sub === "list") {
|
|
159
|
+
const entries = store.listDlq();
|
|
160
|
+
if (entries.length === 0) {
|
|
161
|
+
process.stdout.write("DLQ is empty\n");
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
for (const c of entries) {
|
|
165
|
+
process.stdout.write(`${c.cardId} attempts=${c.attempts} cost=$${(c.totalCostCents / 100).toFixed(2)} reason=${c.dlqReason ?? "(unknown)"}\n`);
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
if (sub === "clear") {
|
|
170
|
+
const cardId = args[1];
|
|
171
|
+
if (!cardId) {
|
|
172
|
+
process.stderr.write("usage: harmony-agent dlq clear <cardId>\n");
|
|
173
|
+
return 2;
|
|
174
|
+
}
|
|
175
|
+
// Prefer the running daemon if present — direct file writes race
|
|
176
|
+
// the daemon's own in-memory state-store and silently lose data.
|
|
177
|
+
try {
|
|
178
|
+
const res = await httpCall(`/dlq/clear/${encodeURIComponent(cardId)}`, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
});
|
|
181
|
+
if (res.ok) {
|
|
182
|
+
process.stdout.write(`cleared DLQ for ${cardId} (via daemon)\n`);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
process.stderr.write(`daemon returned ${res.status} ${res.statusText}\n`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
if (!isDaemonDown(err)) {
|
|
190
|
+
process.stderr.write(`daemon HTTP error: ${err instanceof Error ? err.message : err}\n`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
// Daemon offline → safe to write directly.
|
|
194
|
+
await store.clearDlq(cardId);
|
|
195
|
+
process.stdout.write(`cleared DLQ for ${cardId} (daemon offline, wrote directly)\n`);
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
process.stderr.write(`unknown dlq subcommand: ${sub}\n`);
|
|
200
|
+
return 2;
|
|
201
|
+
}
|
|
202
|
+
async function dispatch(argv) {
|
|
203
|
+
// Strip node, script, and any global flags we own.
|
|
204
|
+
const args = argv.filter((a) => a !== "--pretty" && a !== "--json");
|
|
205
|
+
const cmd = args[0];
|
|
206
|
+
switch (cmd) {
|
|
207
|
+
case undefined:
|
|
208
|
+
case "run":
|
|
209
|
+
case "daemon":
|
|
210
|
+
await runDaemon();
|
|
211
|
+
return 0;
|
|
212
|
+
case "status":
|
|
213
|
+
return statusCommand();
|
|
214
|
+
case "health":
|
|
215
|
+
return healthCommand();
|
|
216
|
+
case "doctor":
|
|
217
|
+
return doctorCommand();
|
|
218
|
+
case "gc":
|
|
219
|
+
return gcCommand();
|
|
220
|
+
case "dlq":
|
|
221
|
+
return dlqCommand(args.slice(1));
|
|
222
|
+
case "help":
|
|
223
|
+
case "--help":
|
|
224
|
+
case "-h":
|
|
225
|
+
process.stdout.write(`${USAGE}\n`);
|
|
226
|
+
return 0;
|
|
227
|
+
default:
|
|
228
|
+
process.stderr.write(`unknown subcommand: ${cmd}\n${USAGE}\n`);
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
dispatch(process.argv.slice(2))
|
|
233
|
+
.then((code) => {
|
|
234
|
+
if (code !== 0)
|
|
235
|
+
process.exit(code);
|
|
236
|
+
})
|
|
237
|
+
.catch((err) => {
|
|
238
|
+
log.error("cli", err instanceof Error ? err.message : String(err));
|
|
239
|
+
process.exit(1);
|
|
240
|
+
});
|
package/dist/completion.d.ts
CHANGED
|
@@ -8,6 +8,15 @@ export interface SessionStats {
|
|
|
8
8
|
toolCalls: number;
|
|
9
9
|
cost: CostUpdate | null;
|
|
10
10
|
}
|
|
11
|
+
export declare function buildTokenPayload(stats?: SessionStats | null): {
|
|
12
|
+
costCents?: undefined;
|
|
13
|
+
inputTokens?: undefined;
|
|
14
|
+
outputTokens?: undefined;
|
|
15
|
+
} | {
|
|
16
|
+
costCents: number;
|
|
17
|
+
inputTokens: number;
|
|
18
|
+
outputTokens: number;
|
|
19
|
+
};
|
|
11
20
|
/**
|
|
12
21
|
* Post-work pipeline: push branch, create PR, move card, post summary.
|
|
13
22
|
*/
|
package/dist/completion.js
CHANGED
|
@@ -6,6 +6,22 @@ import { AGENT_NAME, agentIdentifier } from "./types.js";
|
|
|
6
6
|
import { attemptAutoFix, reportFindings, runVerification, } from "./verification.js";
|
|
7
7
|
import { cleanupWorktree } from "./worktree.js";
|
|
8
8
|
const TAG = "completion";
|
|
9
|
+
function formatTokenCount(tokens) {
|
|
10
|
+
if (tokens >= 1_000_000)
|
|
11
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
12
|
+
if (tokens >= 1_000)
|
|
13
|
+
return `${(tokens / 1_000).toFixed(1)}k`;
|
|
14
|
+
return String(tokens);
|
|
15
|
+
}
|
|
16
|
+
export function buildTokenPayload(stats) {
|
|
17
|
+
if (!stats?.cost)
|
|
18
|
+
return {};
|
|
19
|
+
return {
|
|
20
|
+
costCents: Math.round(stats.cost.totalCostUsd * 100),
|
|
21
|
+
inputTokens: stats.cost.totalInputTokens,
|
|
22
|
+
outputTokens: stats.cost.totalOutputTokens,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
9
25
|
/**
|
|
10
26
|
* Post-work pipeline: push branch, create PR, move card, post summary.
|
|
11
27
|
*/
|
|
@@ -17,6 +33,7 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
|
|
|
17
33
|
await client.endAgentSession(card.id, {
|
|
18
34
|
status: "completed",
|
|
19
35
|
progressPercent: 100,
|
|
36
|
+
...buildTokenPayload(sessionStats),
|
|
20
37
|
});
|
|
21
38
|
cleanupWorktree(worktreePath, branchName);
|
|
22
39
|
return;
|
|
@@ -54,7 +71,10 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
|
|
|
54
71
|
log.warn(TAG, `Verification failed for #${card.short_id} — reporting findings`);
|
|
55
72
|
await reportFindings(client, card.id, result);
|
|
56
73
|
await moveCardToColumn(client, card, config.verification.failColumn);
|
|
57
|
-
await client.endAgentSession(card.id, {
|
|
74
|
+
await client.endAgentSession(card.id, {
|
|
75
|
+
status: "paused",
|
|
76
|
+
...buildTokenPayload(sessionStats),
|
|
77
|
+
});
|
|
58
78
|
cleanupWorktree(worktreePath, branchName);
|
|
59
79
|
return;
|
|
60
80
|
}
|
|
@@ -77,10 +97,11 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
|
|
|
77
97
|
if (config.completion.postSummary) {
|
|
78
98
|
await postSummary(client, card, branchName, worktreePath, prUrl, config.worktree.baseBranch, sessionStats);
|
|
79
99
|
}
|
|
80
|
-
// 6. End agent session
|
|
100
|
+
// 6. End agent session (include final token/cost snapshot)
|
|
81
101
|
await client.endAgentSession(card.id, {
|
|
82
102
|
status: "completed",
|
|
83
103
|
progressPercent: 100,
|
|
104
|
+
...buildTokenPayload(sessionStats),
|
|
84
105
|
});
|
|
85
106
|
// 7. Cleanup worktree
|
|
86
107
|
cleanupWorktree(worktreePath, branchName);
|
|
@@ -119,6 +140,11 @@ async function postSummary(client, card, branchName, worktreePath, prUrl, baseBr
|
|
|
119
140
|
if (sessionStats.cost) {
|
|
120
141
|
statParts.push(`$${sessionStats.cost.totalCostUsd.toFixed(2)} cost`);
|
|
121
142
|
statParts.push(`${sessionStats.cost.numTurns} turns`);
|
|
143
|
+
const totalTokens = sessionStats.cost.totalInputTokens +
|
|
144
|
+
sessionStats.cost.totalOutputTokens;
|
|
145
|
+
if (totalTokens > 0) {
|
|
146
|
+
statParts.push(`${formatTokenCount(totalTokens)} tokens`);
|
|
147
|
+
}
|
|
122
148
|
}
|
|
123
149
|
parts.push(`Stats: ${statParts.join(" · ")}`);
|
|
124
150
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
+
import type { AgentConfig } from "./types.js";
|
|
3
|
+
export declare class ConfigValidationError extends Error {
|
|
4
|
+
readonly issues: string[];
|
|
5
|
+
constructor(message: string, issues: string[]);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Validate that every column referenced in the agent config actually
|
|
9
|
+
* exists on the board. Labels that the daemon needs to create (agent,
|
|
10
|
+
* Ready to Merge, Merged, Need Review, agent-recovered) are not
|
|
11
|
+
* required to pre-exist — the daemon creates them on demand. Columns
|
|
12
|
+
* must already exist because moving a card to a non-existent column
|
|
13
|
+
* silently fails and leaves cards stuck.
|
|
14
|
+
*
|
|
15
|
+
* Fail-fast on any mismatch: the operator fixes their config OR the
|
|
16
|
+
* board before the daemon is useful.
|
|
17
|
+
*/
|
|
18
|
+
export declare function validateColumnReferences(client: HarmonyApiClient, projectId: string, config: AgentConfig): Promise<void>;
|