@gethmy/agent 1.0.8 → 1.1.0
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__/progress-tracker.test.js +2 -1
- 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 +3 -1
- 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 +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { log } from "./log.js";
|
|
2
|
+
const TAG = "config-validation";
|
|
3
|
+
export class ConfigValidationError extends Error {
|
|
4
|
+
issues;
|
|
5
|
+
constructor(message, issues) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.issues = issues;
|
|
8
|
+
this.name = "ConfigValidationError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function columnNames(board) {
|
|
12
|
+
return board.columns.map((c) => c.name);
|
|
13
|
+
}
|
|
14
|
+
function findColumn(board, name) {
|
|
15
|
+
const target = name.toLowerCase();
|
|
16
|
+
return board.columns.some((c) => c.name.toLowerCase() === target);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Validate that every column referenced in the agent config actually
|
|
20
|
+
* exists on the board. Labels that the daemon needs to create (agent,
|
|
21
|
+
* Ready to Merge, Merged, Need Review, agent-recovered) are not
|
|
22
|
+
* required to pre-exist — the daemon creates them on demand. Columns
|
|
23
|
+
* must already exist because moving a card to a non-existent column
|
|
24
|
+
* silently fails and leaves cards stuck.
|
|
25
|
+
*
|
|
26
|
+
* Fail-fast on any mismatch: the operator fixes their config OR the
|
|
27
|
+
* board before the daemon is useful.
|
|
28
|
+
*/
|
|
29
|
+
export async function validateColumnReferences(client, projectId, config) {
|
|
30
|
+
const board = (await client.getBoard(projectId, {
|
|
31
|
+
summary: true,
|
|
32
|
+
}));
|
|
33
|
+
const known = columnNames(board);
|
|
34
|
+
const issues = [];
|
|
35
|
+
const required = [
|
|
36
|
+
...config.pickupColumns.map((c) => ({ value: c, where: "pickupColumns" })),
|
|
37
|
+
{
|
|
38
|
+
value: config.completion.moveToColumn,
|
|
39
|
+
where: "completion.moveToColumn",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
value: config.verification.failColumn,
|
|
43
|
+
where: "verification.failColumn",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
if (config.review.enabled) {
|
|
47
|
+
for (const c of config.review.pickupColumns) {
|
|
48
|
+
required.push({ value: c, where: "review.pickupColumns" });
|
|
49
|
+
}
|
|
50
|
+
required.push({ value: config.review.moveToColumn, where: "review.moveToColumn" }, { value: config.review.failColumn, where: "review.failColumn" });
|
|
51
|
+
}
|
|
52
|
+
for (const { value, where } of required) {
|
|
53
|
+
if (!value)
|
|
54
|
+
continue;
|
|
55
|
+
if (!findColumn(board, value)) {
|
|
56
|
+
issues.push(`${where}: column "${value}" not found on board`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (issues.length > 0) {
|
|
60
|
+
const help = `Available columns: ${known.join(", ")}`;
|
|
61
|
+
throw new ConfigValidationError(`Invalid agent config — the following columns are missing:\n - ${issues.join("\n - ")}\n${help}`, issues);
|
|
62
|
+
}
|
|
63
|
+
log.info(TAG, `Validated columns: ${Array.from(new Set(required.map((r) => r.value)))
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(", ")}`);
|
|
66
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -70,6 +70,18 @@ export function loadDaemonConfig() {
|
|
|
70
70
|
...DEFAULT_AGENT_CONFIG.review,
|
|
71
71
|
...(agentOverrides.review ?? {}),
|
|
72
72
|
},
|
|
73
|
+
budget: {
|
|
74
|
+
...DEFAULT_AGENT_CONFIG.budget,
|
|
75
|
+
...(agentOverrides.budget ?? {}),
|
|
76
|
+
},
|
|
77
|
+
http: {
|
|
78
|
+
...DEFAULT_AGENT_CONFIG.http,
|
|
79
|
+
...(agentOverrides.http ?? {}),
|
|
80
|
+
},
|
|
81
|
+
timing: {
|
|
82
|
+
...DEFAULT_AGENT_CONFIG.timing,
|
|
83
|
+
...(agentOverrides.timing ?? {}),
|
|
84
|
+
},
|
|
73
85
|
};
|
|
74
86
|
return { apiKey, apiUrl, workspaceId, projectId, userEmail, agent };
|
|
75
87
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export interface HealthSnapshot {
|
|
2
|
+
healthy: boolean;
|
|
3
|
+
checks: Record<string, boolean>;
|
|
4
|
+
}
|
|
5
|
+
export interface WorkerSnapshot {
|
|
6
|
+
id: number;
|
|
7
|
+
pipeline: "implement" | "review";
|
|
8
|
+
state: string;
|
|
9
|
+
cardId: string | null;
|
|
10
|
+
cardShortId: number | null;
|
|
11
|
+
phase: string | null;
|
|
12
|
+
startedAt: number | null;
|
|
13
|
+
branchName: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface StatusSnapshot {
|
|
16
|
+
daemonId: string;
|
|
17
|
+
daemonPid: number;
|
|
18
|
+
startedAt: number;
|
|
19
|
+
uptimeMs: number;
|
|
20
|
+
workers: WorkerSnapshot[];
|
|
21
|
+
implQueue: Array<{
|
|
22
|
+
cardId: string;
|
|
23
|
+
shortId: number;
|
|
24
|
+
priority: number;
|
|
25
|
+
enqueuedAt: number;
|
|
26
|
+
}>;
|
|
27
|
+
reviewQueue: Array<{
|
|
28
|
+
cardId: string;
|
|
29
|
+
shortId: number;
|
|
30
|
+
priority: number;
|
|
31
|
+
enqueuedAt: number;
|
|
32
|
+
}>;
|
|
33
|
+
dlq: Array<{
|
|
34
|
+
cardId: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
attempts: number;
|
|
37
|
+
totalCostCents: number;
|
|
38
|
+
}>;
|
|
39
|
+
budget: {
|
|
40
|
+
todayCents: number;
|
|
41
|
+
dailyCapCents: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export type Command = "pause" | "resume" | "stop";
|
|
45
|
+
export interface HttpServerOptions {
|
|
46
|
+
port: number;
|
|
47
|
+
bindAddr: string;
|
|
48
|
+
getStatus: () => StatusSnapshot;
|
|
49
|
+
getHealth: () => HealthSnapshot;
|
|
50
|
+
handleCommand: (cmd: Command, cardId: string) => Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Clear a card's DLQ marker. Routed through the daemon (not CLI
|
|
53
|
+
* direct-write) so we never have two processes racing to persist
|
|
54
|
+
* the same state-store file.
|
|
55
|
+
*/
|
|
56
|
+
clearDlq: (cardId: string) => Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Tiny introspection + control surface for the running daemon.
|
|
60
|
+
* Binds to 127.0.0.1 by default so it's not network-reachable.
|
|
61
|
+
*
|
|
62
|
+
* GET /health — 200 when healthy, 503 otherwise. Simple liveness check
|
|
63
|
+
* suitable for process supervisors (systemd, pm2, docker healthcheck).
|
|
64
|
+
* GET /status — full JSON snapshot: workers, queues, DLQ, budget.
|
|
65
|
+
* POST /pause/:cardId, /resume/:cardId, /stop/:cardId — command path
|
|
66
|
+
* so an operator can nudge a stuck worker without killing the daemon.
|
|
67
|
+
*/
|
|
68
|
+
export declare class HttpServer {
|
|
69
|
+
private opts;
|
|
70
|
+
private server;
|
|
71
|
+
constructor(opts: HttpServerOptions);
|
|
72
|
+
start(): Promise<void>;
|
|
73
|
+
stop(): Promise<void>;
|
|
74
|
+
private route;
|
|
75
|
+
private respondDlqClear;
|
|
76
|
+
private respondHealth;
|
|
77
|
+
private respondStatus;
|
|
78
|
+
private respondCommand;
|
|
79
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
|
+
import { log } from "./log.js";
|
|
3
|
+
const TAG = "http";
|
|
4
|
+
/**
|
|
5
|
+
* Tiny introspection + control surface for the running daemon.
|
|
6
|
+
* Binds to 127.0.0.1 by default so it's not network-reachable.
|
|
7
|
+
*
|
|
8
|
+
* GET /health — 200 when healthy, 503 otherwise. Simple liveness check
|
|
9
|
+
* suitable for process supervisors (systemd, pm2, docker healthcheck).
|
|
10
|
+
* GET /status — full JSON snapshot: workers, queues, DLQ, budget.
|
|
11
|
+
* POST /pause/:cardId, /resume/:cardId, /stop/:cardId — command path
|
|
12
|
+
* so an operator can nudge a stuck worker without killing the daemon.
|
|
13
|
+
*/
|
|
14
|
+
export class HttpServer {
|
|
15
|
+
opts;
|
|
16
|
+
server = null;
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.opts = opts;
|
|
19
|
+
}
|
|
20
|
+
async start() {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
this.server = createServer((req, res) => {
|
|
23
|
+
this.route(req, res).catch((err) => {
|
|
24
|
+
log.error(TAG, `unhandled: ${err instanceof Error ? err.message : err}`);
|
|
25
|
+
if (!res.headersSent) {
|
|
26
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
27
|
+
res.end(JSON.stringify({ error: "internal_error" }));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
this.server.once("error", reject);
|
|
32
|
+
this.server.listen(this.opts.port, this.opts.bindAddr, () => {
|
|
33
|
+
log.info(TAG, `listening on http://${this.opts.bindAddr}:${this.opts.port}`);
|
|
34
|
+
resolve();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async stop() {
|
|
39
|
+
if (!this.server)
|
|
40
|
+
return;
|
|
41
|
+
await new Promise((resolve) => {
|
|
42
|
+
this.server?.close(() => resolve());
|
|
43
|
+
});
|
|
44
|
+
this.server = null;
|
|
45
|
+
}
|
|
46
|
+
async route(req, res) {
|
|
47
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
48
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
49
|
+
const path = url.pathname;
|
|
50
|
+
if (method === "GET" && path === "/health") {
|
|
51
|
+
return this.respondHealth(res);
|
|
52
|
+
}
|
|
53
|
+
if (method === "GET" && path === "/status") {
|
|
54
|
+
return this.respondStatus(res);
|
|
55
|
+
}
|
|
56
|
+
if (method === "POST") {
|
|
57
|
+
const dlq = path.match(/^\/dlq\/clear\/([^/]+)$/);
|
|
58
|
+
if (dlq) {
|
|
59
|
+
return this.respondDlqClear(res, decodeURIComponent(dlq[1]));
|
|
60
|
+
}
|
|
61
|
+
const cmd = parseCommand(path);
|
|
62
|
+
if (cmd) {
|
|
63
|
+
return this.respondCommand(res, cmd.command, cmd.cardId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
67
|
+
res.end(JSON.stringify({ error: "not_found", path }));
|
|
68
|
+
}
|
|
69
|
+
async respondDlqClear(res, cardId) {
|
|
70
|
+
try {
|
|
71
|
+
await this.opts.clearDlq(cardId);
|
|
72
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
73
|
+
res.end(JSON.stringify({ ok: true, cardId }));
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
77
|
+
res.end(JSON.stringify({
|
|
78
|
+
error: "clear_dlq_failed",
|
|
79
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
respondHealth(res) {
|
|
84
|
+
const health = this.opts.getHealth();
|
|
85
|
+
res.writeHead(health.healthy ? 200 : 503, {
|
|
86
|
+
"content-type": "application/json",
|
|
87
|
+
});
|
|
88
|
+
res.end(JSON.stringify(health));
|
|
89
|
+
}
|
|
90
|
+
respondStatus(res) {
|
|
91
|
+
const snapshot = this.opts.getStatus();
|
|
92
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
93
|
+
res.end(JSON.stringify(snapshot));
|
|
94
|
+
}
|
|
95
|
+
async respondCommand(res, command, cardId) {
|
|
96
|
+
try {
|
|
97
|
+
await this.opts.handleCommand(command, cardId);
|
|
98
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
99
|
+
res.end(JSON.stringify({ ok: true, command, cardId }));
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
103
|
+
res.end(JSON.stringify({
|
|
104
|
+
error: "command_failed",
|
|
105
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function parseCommand(path) {
|
|
111
|
+
const match = path.match(/^\/(pause|resume|stop)\/([^/]+)$/);
|
|
112
|
+
if (!match)
|
|
113
|
+
return null;
|
|
114
|
+
return { command: match[1], cardId: decodeURIComponent(match[2]) };
|
|
115
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { buildLabelMap, hasLabel, resolveCardLabels } from "./board-helpers.js";
|
|
3
4
|
import { createApiClient, fetchRealtimeCredentials, loadDaemonConfig, } from "./config.js";
|
|
5
|
+
import { ConfigValidationError, validateColumnReferences, } from "./config-validation.js";
|
|
4
6
|
import { detectGitProvider, validateGitProviderCli } from "./git-pr.js";
|
|
7
|
+
import { HttpServer } from "./http-server.js";
|
|
5
8
|
import { log } from "./log.js";
|
|
6
9
|
import { MergeMonitor } from "./merge-monitor.js";
|
|
7
10
|
import { Pool } from "./pool.js";
|
|
8
11
|
import { Reconciler } from "./reconcile.js";
|
|
12
|
+
import { recoverOrphans } from "./recovery.js";
|
|
13
|
+
import { extractBranchFromDescription } from "./review-worktree.js";
|
|
14
|
+
import { StateStore } from "./state-store.js";
|
|
9
15
|
import { Watcher } from "./watcher.js";
|
|
16
|
+
import { WorktreeGc } from "./worktree-gc.js";
|
|
10
17
|
const TAG = "daemon";
|
|
11
18
|
// ============ STARTUP VALIDATION ============
|
|
12
19
|
async function validatePrerequisites(config) {
|
|
@@ -70,7 +77,8 @@ async function validatePrerequisites(config) {
|
|
|
70
77
|
return agentMember.userId;
|
|
71
78
|
}
|
|
72
79
|
// ============ MAIN DAEMON ============
|
|
73
|
-
|
|
80
|
+
export { validatePrerequisites };
|
|
81
|
+
export async function main() {
|
|
74
82
|
log.info(TAG, "Harmony Agent Daemon starting...");
|
|
75
83
|
// Load config
|
|
76
84
|
const config = loadDaemonConfig();
|
|
@@ -83,11 +91,32 @@ async function main() {
|
|
|
83
91
|
log.info(TAG, `Agent user: ${config.userEmail} (${agentUserId})`);
|
|
84
92
|
// Create API client
|
|
85
93
|
const client = createApiClient(config);
|
|
94
|
+
// Fail fast if configured columns don't exist on the board. A stuck
|
|
95
|
+
// card with no log output is worse than a clear startup error.
|
|
96
|
+
try {
|
|
97
|
+
await validateColumnReferences(client, config.projectId, config.agent);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err instanceof ConfigValidationError) {
|
|
101
|
+
log.error(TAG, err.message);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
// Durable run/card state lives outside the process. Recover orphans
|
|
107
|
+
// from any crashed prior daemon BEFORE we start accepting new work.
|
|
108
|
+
const stateStore = StateStore.open();
|
|
109
|
+
const daemonId = randomUUID();
|
|
110
|
+
await stateStore.setDaemon(daemonId, process.pid);
|
|
111
|
+
const outcomes = await recoverOrphans(stateStore, client, config.agent);
|
|
112
|
+
if (outcomes.length > 0) {
|
|
113
|
+
log.info(TAG, `recovery: ${outcomes.length} orphan(s) handled, ${outcomes.filter((o) => o.errors.length).length} had errors`);
|
|
114
|
+
}
|
|
86
115
|
// Fetch realtime credentials
|
|
87
116
|
const realtimeCreds = await fetchRealtimeCredentials(client);
|
|
88
117
|
log.info(TAG, "Realtime credentials fetched");
|
|
89
118
|
// Create pool
|
|
90
|
-
const pool = new Pool(config.agent, client, config.userEmail, config.workspaceId, config.projectId);
|
|
119
|
+
const pool = new Pool(config.agent, client, config.userEmail, config.workspaceId, config.projectId, stateStore);
|
|
91
120
|
// Create reconciler
|
|
92
121
|
const reviewColumns = config.agent.review.enabled
|
|
93
122
|
? config.agent.review.pickupColumns
|
|
@@ -95,12 +124,74 @@ async function main() {
|
|
|
95
124
|
const approvedLabel = config.agent.review.enabled
|
|
96
125
|
? config.agent.review.approvedLabel
|
|
97
126
|
: "";
|
|
98
|
-
const reconciler = new Reconciler(client, pool, config.projectId, agentUserId, config.agent.pickupColumns, reviewColumns, approvedLabel,
|
|
127
|
+
const reconciler = new Reconciler(client, pool, config.projectId, agentUserId, config.agent.pickupColumns, reviewColumns, approvedLabel, config.agent.timing.reconcileIntervalMs, stateStore, config.agent);
|
|
99
128
|
// Create merge monitor (polls PR merge status for "Ready to Merge" cards)
|
|
100
129
|
let mergeMonitor = null;
|
|
101
130
|
if (config.agent.review.enabled && config.agent.review.mergeMonitor) {
|
|
102
131
|
mergeMonitor = new MergeMonitor(client, config.projectId, config.agent);
|
|
103
132
|
}
|
|
133
|
+
// Periodic worktree GC — removes orphan worktrees older than 1h that
|
|
134
|
+
// no active run claims. Covers crashes that left worktrees behind.
|
|
135
|
+
const worktreeGc = new WorktreeGc(config.agent.worktree.basePath, stateStore, config.agent.timing.worktreeGcIntervalMs);
|
|
136
|
+
// Local status/control HTTP server.
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
const httpServer = config.agent.http.enabled
|
|
139
|
+
? new HttpServer({
|
|
140
|
+
port: config.agent.http.port,
|
|
141
|
+
bindAddr: config.agent.http.bindAddr,
|
|
142
|
+
getHealth: () => {
|
|
143
|
+
const lastTick = reconciler.lastTick;
|
|
144
|
+
const reconcilerFresh = lastTick !== null &&
|
|
145
|
+
Date.now() - lastTick < config.agent.timing.reconcileIntervalMs * 2;
|
|
146
|
+
const checks = {
|
|
147
|
+
watcher: watcher.isConnected,
|
|
148
|
+
reconciler: reconciler.isRunning && reconcilerFresh,
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
healthy: Object.values(checks).every(Boolean),
|
|
152
|
+
checks,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
getStatus: () => {
|
|
156
|
+
const queues = pool.snapshotQueues();
|
|
157
|
+
const dlq = stateStore.listDlq().map((c) => ({
|
|
158
|
+
cardId: c.cardId,
|
|
159
|
+
reason: c.dlqReason ?? "unknown",
|
|
160
|
+
attempts: c.attempts,
|
|
161
|
+
totalCostCents: c.totalCostCents,
|
|
162
|
+
}));
|
|
163
|
+
return {
|
|
164
|
+
daemonId,
|
|
165
|
+
daemonPid: process.pid,
|
|
166
|
+
startedAt,
|
|
167
|
+
uptimeMs: Date.now() - startedAt,
|
|
168
|
+
workers: pool.snapshotWorkers().map((w) => ({
|
|
169
|
+
...w,
|
|
170
|
+
phase: null,
|
|
171
|
+
})),
|
|
172
|
+
implQueue: queues.impl.map((i) => ({
|
|
173
|
+
cardId: i.cardId,
|
|
174
|
+
shortId: i.shortId,
|
|
175
|
+
priority: i.priority,
|
|
176
|
+
enqueuedAt: i.enqueuedAt,
|
|
177
|
+
})),
|
|
178
|
+
reviewQueue: queues.review.map((i) => ({
|
|
179
|
+
cardId: i.cardId,
|
|
180
|
+
shortId: i.shortId,
|
|
181
|
+
priority: i.priority,
|
|
182
|
+
enqueuedAt: i.enqueuedAt,
|
|
183
|
+
})),
|
|
184
|
+
dlq,
|
|
185
|
+
budget: {
|
|
186
|
+
todayCents: stateStore.getDailyCostCents(),
|
|
187
|
+
dailyCapCents: config.agent.budget.dailyBudgetCents,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
handleCommand: (cmd, cardId) => pool.handleAgentCommand(cardId, cmd),
|
|
192
|
+
clearDlq: (cardId) => stateStore.clearDlq(cardId),
|
|
193
|
+
})
|
|
194
|
+
: null;
|
|
104
195
|
// Create watcher (broadcast events from harmony-api + agent commands from UI)
|
|
105
196
|
const watcher = new Watcher(realtimeCreds, config.projectId, async (event) => {
|
|
106
197
|
await handleBroadcast(event, client, pool, config, agentUserId);
|
|
@@ -109,6 +200,7 @@ async function main() {
|
|
|
109
200
|
});
|
|
110
201
|
// Wire up shutdown
|
|
111
202
|
let shuttingDown = false;
|
|
203
|
+
let exitCode = 0;
|
|
112
204
|
const shutdown = async (signal) => {
|
|
113
205
|
if (shuttingDown)
|
|
114
206
|
return;
|
|
@@ -116,17 +208,38 @@ async function main() {
|
|
|
116
208
|
log.info(TAG, `Received ${signal}, shutting down gracefully...`);
|
|
117
209
|
reconciler.stop();
|
|
118
210
|
mergeMonitor?.stop();
|
|
211
|
+
worktreeGc.stop();
|
|
212
|
+
await httpServer?.stop();
|
|
119
213
|
await watcher.stop();
|
|
120
214
|
await pool.shutdown();
|
|
121
215
|
log.info(TAG, "Daemon stopped.");
|
|
122
|
-
process.exit(
|
|
216
|
+
process.exit(exitCode);
|
|
123
217
|
};
|
|
124
218
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
125
219
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
220
|
+
process.on("uncaughtException", (err) => {
|
|
221
|
+
log.error(TAG, `Uncaught exception: ${err.message}`);
|
|
222
|
+
exitCode = 1;
|
|
223
|
+
shutdown("uncaughtException");
|
|
224
|
+
});
|
|
225
|
+
process.on("unhandledRejection", (reason) => {
|
|
226
|
+
log.error(TAG, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
227
|
+
exitCode = 1;
|
|
228
|
+
shutdown("unhandledRejection");
|
|
229
|
+
});
|
|
126
230
|
// Start everything
|
|
127
231
|
await watcher.start();
|
|
128
232
|
reconciler.start();
|
|
129
233
|
mergeMonitor?.start();
|
|
234
|
+
worktreeGc.start();
|
|
235
|
+
if (httpServer) {
|
|
236
|
+
try {
|
|
237
|
+
await httpServer.start();
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
log.warn(TAG, `HTTP server failed to bind: ${err instanceof Error ? err.message : err}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
130
243
|
log.info(TAG, "Daemon is running. Watching for card assignments...");
|
|
131
244
|
}
|
|
132
245
|
// ============ EVENT HANDLER ============
|
|
@@ -190,10 +303,12 @@ async function tryEnqueueCard(cardId, client, pool, config) {
|
|
|
190
303
|
log.debug(TAG, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
|
|
191
304
|
return;
|
|
192
305
|
}
|
|
193
|
-
|
|
306
|
+
// Skip review for cards without a branch reference — not qualified for auto-review
|
|
307
|
+
if (mode === "review" && !extractBranchFromDescription(card.description)) {
|
|
308
|
+
log.info(TAG, `Card #${card.short_id} has no branch reference — skipping auto-review`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
await pool.enqueue(card, column, cardLabels, subtasks, mode);
|
|
194
312
|
}
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
log.error(TAG, `Fatal: ${err instanceof Error ? err.message : err}`);
|
|
198
|
-
process.exit(1);
|
|
199
|
-
});
|
|
313
|
+
// The daemon is now launched from cli.ts so other subcommands
|
|
314
|
+
// (status, doctor, gc, dlq) can load this module without side effects.
|
package/dist/log.d.ts
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structured logging for the agent daemon.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* When stderr is a TTY, output is ANSI-coloured single-line text — the
|
|
5
|
+
* readable default for humans running the daemon interactively. When
|
|
6
|
+
* stderr is piped/redirected (files, logshippers, systemd), output
|
|
7
|
+
* falls back to one JSON object per line — trivially `jq`-able,
|
|
8
|
+
* indexable, and machine-readable.
|
|
9
|
+
*
|
|
10
|
+
* Overrides: `--pretty` / HARMONY_AGENT_PRETTY=1 force pretty;
|
|
11
|
+
* `--json` / HARMONY_AGENT_JSON=1 force JSON. Explicit flags beat
|
|
12
|
+
* TTY detection either way.
|
|
13
|
+
*
|
|
14
|
+
* Stdout is left clean so consumers can pipe structured data if they
|
|
15
|
+
* want to (future use).
|
|
4
16
|
*/
|
|
17
|
+
export interface LogContext {
|
|
18
|
+
event?: string;
|
|
19
|
+
runId?: string;
|
|
20
|
+
cardId?: string;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
5
23
|
export declare const log: {
|
|
6
|
-
info(tag: string, msg: string): void;
|
|
7
|
-
warn(tag: string, msg: string): void;
|
|
8
|
-
error(tag: string, msg: string): void;
|
|
9
|
-
debug(tag: string, msg: string): void;
|
|
24
|
+
info(tag: string, msg: string, ctx?: LogContext): void;
|
|
25
|
+
warn(tag: string, msg: string, ctx?: LogContext): void;
|
|
26
|
+
error(tag: string, msg: string, ctx?: LogContext): void;
|
|
27
|
+
debug(tag: string, msg: string, ctx?: LogContext): void;
|
|
28
|
+
/**
|
|
29
|
+
* Emit a named event. Semantically the same as `info` but signals to
|
|
30
|
+
* downstream tooling that this is a structured event worth indexing.
|
|
31
|
+
*/
|
|
32
|
+
event(tag: string, event: string, ctx?: LogContext): void;
|
|
10
33
|
};
|
|
34
|
+
export declare function isPretty(): boolean;
|
package/dist/log.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Structured logging for the agent daemon.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* When stderr is a TTY, output is ANSI-coloured single-line text — the
|
|
5
|
+
* readable default for humans running the daemon interactively. When
|
|
6
|
+
* stderr is piped/redirected (files, logshippers, systemd), output
|
|
7
|
+
* falls back to one JSON object per line — trivially `jq`-able,
|
|
8
|
+
* indexable, and machine-readable.
|
|
9
|
+
*
|
|
10
|
+
* Overrides: `--pretty` / HARMONY_AGENT_PRETTY=1 force pretty;
|
|
11
|
+
* `--json` / HARMONY_AGENT_JSON=1 force JSON. Explicit flags beat
|
|
12
|
+
* TTY detection either way.
|
|
13
|
+
*
|
|
14
|
+
* Stdout is left clean so consumers can pipe structured data if they
|
|
15
|
+
* want to (future use).
|
|
4
16
|
*/
|
|
5
17
|
const COLORS = {
|
|
6
18
|
reset: "\x1b[0m",
|
|
@@ -11,25 +23,78 @@ const COLORS = {
|
|
|
11
23
|
blue: "\x1b[34m",
|
|
12
24
|
cyan: "\x1b[36m",
|
|
13
25
|
};
|
|
14
|
-
|
|
15
|
-
|
|
26
|
+
const LEVEL_COLOR = {
|
|
27
|
+
debug: COLORS.dim,
|
|
28
|
+
info: COLORS.green,
|
|
29
|
+
warn: COLORS.yellow,
|
|
30
|
+
error: COLORS.red,
|
|
31
|
+
};
|
|
32
|
+
function pretty() {
|
|
33
|
+
if (process.env.HARMONY_AGENT_JSON === "1" ||
|
|
34
|
+
process.argv.includes("--json")) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (process.env.HARMONY_AGENT_PRETTY === "1")
|
|
38
|
+
return true;
|
|
39
|
+
if (process.argv.includes("--pretty"))
|
|
40
|
+
return true;
|
|
41
|
+
return Boolean(process.stderr.isTTY);
|
|
42
|
+
}
|
|
43
|
+
function shortTime(iso) {
|
|
44
|
+
return iso.slice(11, 23);
|
|
45
|
+
}
|
|
46
|
+
function emit(rec) {
|
|
47
|
+
if (rec.level === "debug" && !process.env.DEBUG)
|
|
48
|
+
return;
|
|
49
|
+
if (pretty()) {
|
|
50
|
+
const color = LEVEL_COLOR[rec.level];
|
|
51
|
+
const label = rec.level.toUpperCase().padEnd(5, " ");
|
|
52
|
+
const ctx = [];
|
|
53
|
+
if (rec.event)
|
|
54
|
+
ctx.push(`event=${rec.event}`);
|
|
55
|
+
if (rec.runId)
|
|
56
|
+
ctx.push(`run=${rec.runId}`);
|
|
57
|
+
if (rec.cardId)
|
|
58
|
+
ctx.push(`card=${rec.cardId}`);
|
|
59
|
+
const tail = ctx.length
|
|
60
|
+
? ` ${COLORS.dim}(${ctx.join(" ")})${COLORS.reset}`
|
|
61
|
+
: "";
|
|
62
|
+
process.stderr.write(`${COLORS.dim}${shortTime(rec.ts)}${COLORS.reset} ${color}${label}${COLORS.reset} ${COLORS.cyan}[${rec.tag}]${COLORS.reset} ${rec.msg}${tail}\n`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
process.stderr.write(`${JSON.stringify(rec)}\n`);
|
|
16
66
|
}
|
|
17
|
-
function
|
|
18
|
-
|
|
67
|
+
function record(level, tag, msg, ctx) {
|
|
68
|
+
const rec = {
|
|
69
|
+
ts: new Date().toISOString(),
|
|
70
|
+
level,
|
|
71
|
+
tag,
|
|
72
|
+
msg,
|
|
73
|
+
...(ctx ?? {}),
|
|
74
|
+
};
|
|
75
|
+
emit(rec);
|
|
19
76
|
}
|
|
20
77
|
export const log = {
|
|
21
|
-
info(tag, msg) {
|
|
22
|
-
|
|
78
|
+
info(tag, msg, ctx) {
|
|
79
|
+
record("info", tag, msg, ctx);
|
|
23
80
|
},
|
|
24
|
-
warn(tag, msg) {
|
|
25
|
-
|
|
81
|
+
warn(tag, msg, ctx) {
|
|
82
|
+
record("warn", tag, msg, ctx);
|
|
26
83
|
},
|
|
27
|
-
error(tag, msg) {
|
|
28
|
-
|
|
84
|
+
error(tag, msg, ctx) {
|
|
85
|
+
record("error", tag, msg, ctx);
|
|
29
86
|
},
|
|
30
|
-
debug(tag, msg) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
87
|
+
debug(tag, msg, ctx) {
|
|
88
|
+
record("debug", tag, msg, ctx);
|
|
89
|
+
},
|
|
90
|
+
/**
|
|
91
|
+
* Emit a named event. Semantically the same as `info` but signals to
|
|
92
|
+
* downstream tooling that this is a structured event worth indexing.
|
|
93
|
+
*/
|
|
94
|
+
event(tag, event, ctx) {
|
|
95
|
+
record("info", tag, event, { ...ctx, event });
|
|
34
96
|
},
|
|
35
97
|
};
|
|
98
|
+
export function isPretty() {
|
|
99
|
+
return pretty();
|
|
100
|
+
}
|