@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,216 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
const TAG = "state-store";
|
|
6
|
+
const SCHEMA_VERSION = 1;
|
|
7
|
+
function emptyState() {
|
|
8
|
+
return {
|
|
9
|
+
version: SCHEMA_VERSION,
|
|
10
|
+
daemonId: null,
|
|
11
|
+
daemonPid: null,
|
|
12
|
+
daemonStartedAt: null,
|
|
13
|
+
runs: [],
|
|
14
|
+
cards: [],
|
|
15
|
+
daily: [],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function todayUtc() {
|
|
19
|
+
return new Date().toISOString().slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
export function newRunId() {
|
|
22
|
+
const ts = Date.now().toString(36);
|
|
23
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
24
|
+
return `r_${ts}_${rand}`;
|
|
25
|
+
}
|
|
26
|
+
export function defaultStatePath() {
|
|
27
|
+
return join(homedir(), ".harmony-mcp", "agent-state.json");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Durable run-and-card state for the agent daemon. One writer (this daemon),
|
|
31
|
+
* so in-process serialization via a promise chain is sufficient — no file locks.
|
|
32
|
+
* Persists to JSON via write-to-tmp + rename, which is atomic on POSIX.
|
|
33
|
+
*/
|
|
34
|
+
export class StateStore {
|
|
35
|
+
path;
|
|
36
|
+
state;
|
|
37
|
+
writeQueue = Promise.resolve();
|
|
38
|
+
constructor(path) {
|
|
39
|
+
this.path = path;
|
|
40
|
+
this.state = this.load();
|
|
41
|
+
}
|
|
42
|
+
static open(path) {
|
|
43
|
+
const resolved = path ?? defaultStatePath();
|
|
44
|
+
const dir = dirname(resolved);
|
|
45
|
+
if (!existsSync(dir))
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
return new StateStore(resolved);
|
|
48
|
+
}
|
|
49
|
+
load() {
|
|
50
|
+
if (!existsSync(this.path))
|
|
51
|
+
return emptyState();
|
|
52
|
+
try {
|
|
53
|
+
const raw = readFileSync(this.path, "utf-8");
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
if (parsed?.version !== SCHEMA_VERSION) {
|
|
56
|
+
log.warn(TAG, `state file has version ${parsed?.version}, expected ${SCHEMA_VERSION} — starting fresh`);
|
|
57
|
+
return emptyState();
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
version: SCHEMA_VERSION,
|
|
61
|
+
daemonId: parsed.daemonId ?? null,
|
|
62
|
+
daemonPid: parsed.daemonPid ?? null,
|
|
63
|
+
daemonStartedAt: parsed.daemonStartedAt ?? null,
|
|
64
|
+
runs: parsed.runs ?? [],
|
|
65
|
+
cards: parsed.cards ?? [],
|
|
66
|
+
daily: parsed.daily ?? [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
log.error(TAG, `failed to read state file: ${err instanceof Error ? err.message : err}`);
|
|
71
|
+
return emptyState();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
persist() {
|
|
75
|
+
const fire = async () => {
|
|
76
|
+
const tmp = `${this.path}.tmp`;
|
|
77
|
+
const data = JSON.stringify(this.state, null, 2);
|
|
78
|
+
writeFileSync(tmp, data, "utf-8");
|
|
79
|
+
renameSync(tmp, this.path);
|
|
80
|
+
};
|
|
81
|
+
this.writeQueue = this.writeQueue.then(fire, fire);
|
|
82
|
+
return this.writeQueue;
|
|
83
|
+
}
|
|
84
|
+
/** Await any pending writes. Useful for tests and shutdown. */
|
|
85
|
+
flush() {
|
|
86
|
+
return this.writeQueue;
|
|
87
|
+
}
|
|
88
|
+
// ---------- Daemon metadata ----------
|
|
89
|
+
setDaemon(daemonId, pid) {
|
|
90
|
+
this.state.daemonId = daemonId;
|
|
91
|
+
this.state.daemonPid = pid;
|
|
92
|
+
this.state.daemonStartedAt = Date.now();
|
|
93
|
+
return this.persist();
|
|
94
|
+
}
|
|
95
|
+
getDaemon() {
|
|
96
|
+
return {
|
|
97
|
+
daemonId: this.state.daemonId,
|
|
98
|
+
daemonPid: this.state.daemonPid,
|
|
99
|
+
daemonStartedAt: this.state.daemonStartedAt,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// ---------- Runs ----------
|
|
103
|
+
insertRun(run) {
|
|
104
|
+
this.state.runs.push(run);
|
|
105
|
+
return this.persist();
|
|
106
|
+
}
|
|
107
|
+
updateRun(runId, patch) {
|
|
108
|
+
const idx = this.state.runs.findIndex((r) => r.runId === runId);
|
|
109
|
+
if (idx === -1)
|
|
110
|
+
return Promise.resolve();
|
|
111
|
+
this.state.runs[idx] = { ...this.state.runs[idx], ...patch };
|
|
112
|
+
return this.persist();
|
|
113
|
+
}
|
|
114
|
+
heartbeat(runId) {
|
|
115
|
+
return this.updateRun(runId, { lastHeartbeatAt: Date.now() });
|
|
116
|
+
}
|
|
117
|
+
endRun(runId, status, errorMessage) {
|
|
118
|
+
const patch = {
|
|
119
|
+
status,
|
|
120
|
+
endedAt: Date.now(),
|
|
121
|
+
};
|
|
122
|
+
if (errorMessage !== undefined)
|
|
123
|
+
patch.errorMessage = errorMessage;
|
|
124
|
+
return this.updateRun(runId, patch);
|
|
125
|
+
}
|
|
126
|
+
getRun(runId) {
|
|
127
|
+
return this.state.runs.find((r) => r.runId === runId) ?? null;
|
|
128
|
+
}
|
|
129
|
+
getActiveRuns() {
|
|
130
|
+
return this.state.runs.filter((r) => r.endedAt === null);
|
|
131
|
+
}
|
|
132
|
+
getRunsForCard(cardId) {
|
|
133
|
+
return this.state.runs.filter((r) => r.cardId === cardId);
|
|
134
|
+
}
|
|
135
|
+
/** Trim completed runs older than `beforeTs` to keep the file small. */
|
|
136
|
+
purgeOldRuns(beforeTs) {
|
|
137
|
+
this.state.runs = this.state.runs.filter((r) => r.endedAt === null || r.endedAt >= beforeTs);
|
|
138
|
+
return this.persist();
|
|
139
|
+
}
|
|
140
|
+
// ---------- Cards ----------
|
|
141
|
+
ensureCard(cardId) {
|
|
142
|
+
let rec = this.state.cards.find((c) => c.cardId === cardId);
|
|
143
|
+
if (!rec) {
|
|
144
|
+
rec = {
|
|
145
|
+
cardId,
|
|
146
|
+
attempts: 0,
|
|
147
|
+
totalCostCents: 0,
|
|
148
|
+
dlq: false,
|
|
149
|
+
lastAttemptAt: null,
|
|
150
|
+
lastOutcome: null,
|
|
151
|
+
};
|
|
152
|
+
this.state.cards.push(rec);
|
|
153
|
+
}
|
|
154
|
+
return rec;
|
|
155
|
+
}
|
|
156
|
+
getCard(cardId) {
|
|
157
|
+
return this.state.cards.find((c) => c.cardId === cardId) ?? null;
|
|
158
|
+
}
|
|
159
|
+
async incrementAttempt(cardId) {
|
|
160
|
+
const rec = this.ensureCard(cardId);
|
|
161
|
+
rec.attempts += 1;
|
|
162
|
+
rec.lastAttemptAt = Date.now();
|
|
163
|
+
await this.persist();
|
|
164
|
+
return rec.attempts;
|
|
165
|
+
}
|
|
166
|
+
async recordOutcome(cardId, outcome) {
|
|
167
|
+
const rec = this.ensureCard(cardId);
|
|
168
|
+
rec.lastOutcome = outcome;
|
|
169
|
+
if (outcome === "success")
|
|
170
|
+
rec.attempts = 0;
|
|
171
|
+
await this.persist();
|
|
172
|
+
}
|
|
173
|
+
async addCost(cardId, cents) {
|
|
174
|
+
if (cents <= 0)
|
|
175
|
+
return;
|
|
176
|
+
const rec = this.ensureCard(cardId);
|
|
177
|
+
rec.totalCostCents += cents;
|
|
178
|
+
const today = todayUtc();
|
|
179
|
+
let daily = this.state.daily.find((d) => d.date === today);
|
|
180
|
+
if (!daily) {
|
|
181
|
+
daily = { date: today, costCents: 0 };
|
|
182
|
+
this.state.daily.push(daily);
|
|
183
|
+
}
|
|
184
|
+
daily.costCents += cents;
|
|
185
|
+
const cutoff = new Date(Date.now() - 30 * 86_400_000)
|
|
186
|
+
.toISOString()
|
|
187
|
+
.slice(0, 10);
|
|
188
|
+
this.state.daily = this.state.daily.filter((d) => d.date >= cutoff);
|
|
189
|
+
await this.persist();
|
|
190
|
+
}
|
|
191
|
+
getDailyCostCents(date) {
|
|
192
|
+
const key = date ?? todayUtc();
|
|
193
|
+
return this.state.daily.find((d) => d.date === key)?.costCents ?? 0;
|
|
194
|
+
}
|
|
195
|
+
// ---------- DLQ ----------
|
|
196
|
+
async markDlq(cardId, reason) {
|
|
197
|
+
const rec = this.ensureCard(cardId);
|
|
198
|
+
rec.dlq = true;
|
|
199
|
+
rec.dlqReason = reason;
|
|
200
|
+
await this.persist();
|
|
201
|
+
}
|
|
202
|
+
async clearDlq(cardId) {
|
|
203
|
+
const rec = this.getCard(cardId);
|
|
204
|
+
if (!rec)
|
|
205
|
+
return;
|
|
206
|
+
rec.dlq = false;
|
|
207
|
+
delete rec.dlqReason;
|
|
208
|
+
await this.persist();
|
|
209
|
+
}
|
|
210
|
+
isDlq(cardId) {
|
|
211
|
+
return this.getCard(cardId)?.dlq === true;
|
|
212
|
+
}
|
|
213
|
+
listDlq() {
|
|
214
|
+
return this.state.cards.filter((c) => c.dlq);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
export type TransitionStep = "move" | "addLabel" | "removeLabel" | "updateCard" | "endSession";
|
|
5
|
+
export declare class TransitionError extends Error {
|
|
6
|
+
readonly step: TransitionStep;
|
|
7
|
+
readonly attempts: number;
|
|
8
|
+
readonly detail: string;
|
|
9
|
+
constructor(step: TransitionStep, attempts: number, detail: string, options?: {
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export interface EndSessionArgs {
|
|
14
|
+
status: "completed" | "paused";
|
|
15
|
+
progressPercent?: number;
|
|
16
|
+
costCents?: number;
|
|
17
|
+
inputTokens?: number;
|
|
18
|
+
outputTokens?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface TransitionPlan {
|
|
21
|
+
/** Target column name. No-op if the card is already there. */
|
|
22
|
+
move?: {
|
|
23
|
+
columnName: string;
|
|
24
|
+
};
|
|
25
|
+
/** Labels to add (idempotent — existing labels skipped). */
|
|
26
|
+
addLabels?: Array<{
|
|
27
|
+
name: string;
|
|
28
|
+
color?: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** Labels to remove (idempotent — missing labels skipped). */
|
|
31
|
+
removeLabels?: string[];
|
|
32
|
+
/** Arbitrary card field updates. */
|
|
33
|
+
updateCard?: {
|
|
34
|
+
description?: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
};
|
|
37
|
+
/** End the active agent session. */
|
|
38
|
+
endSession?: EndSessionArgs;
|
|
39
|
+
}
|
|
40
|
+
export interface TransitionOptions {
|
|
41
|
+
retries?: number;
|
|
42
|
+
backoffMs?: number;
|
|
43
|
+
store?: StateStore;
|
|
44
|
+
runId?: string;
|
|
45
|
+
/** If true, a missing column throws instead of being a no-op. */
|
|
46
|
+
strictColumn?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Execute a board transition as an ordered, retriable sequence of steps.
|
|
50
|
+
*
|
|
51
|
+
* Replaces the scattered `try { ... } catch { /* best-effort * / }` pattern
|
|
52
|
+
* so every partial failure surfaces with structured context. Operations
|
|
53
|
+
* are individually idempotent (move-to-same-column is a no-op, add/remove
|
|
54
|
+
* label checks existing state) so re-running a partially-succeeded
|
|
55
|
+
* transition is safe.
|
|
56
|
+
*/
|
|
57
|
+
export declare function runTransition(client: HarmonyApiClient, card: Card, plan: TransitionPlan, opts?: TransitionOptions): Promise<void>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { log } from "./log.js";
|
|
2
|
+
const TAG = "transition";
|
|
3
|
+
export class TransitionError extends Error {
|
|
4
|
+
step;
|
|
5
|
+
attempts;
|
|
6
|
+
detail;
|
|
7
|
+
constructor(step, attempts, detail, options) {
|
|
8
|
+
super(`${step} failed after ${attempts} attempt(s): ${detail}`, options);
|
|
9
|
+
this.step = step;
|
|
10
|
+
this.attempts = attempts;
|
|
11
|
+
this.detail = detail;
|
|
12
|
+
this.name = "TransitionError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Retry a flaky step with exponential backoff. Only the last error is
|
|
17
|
+
* thrown — intermediate errors are logged as warnings so operators can
|
|
18
|
+
* see that retries happened without losing the final cause.
|
|
19
|
+
*/
|
|
20
|
+
async function withRetry(step, cardShortId, op, attempts, backoffMs) {
|
|
21
|
+
let lastErr;
|
|
22
|
+
for (let i = 0; i < attempts; i++) {
|
|
23
|
+
try {
|
|
24
|
+
return await op();
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
lastErr = err;
|
|
28
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
29
|
+
if (i < attempts - 1) {
|
|
30
|
+
const wait = backoffMs * 2 ** i;
|
|
31
|
+
log.warn(TAG, `${step} failed for #${cardShortId} (attempt ${i + 1}/${attempts}): ${msg} — retrying in ${wait}ms`);
|
|
32
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
37
|
+
throw new TransitionError(step, attempts, msg, { cause: lastErr });
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Execute a board transition as an ordered, retriable sequence of steps.
|
|
41
|
+
*
|
|
42
|
+
* Replaces the scattered `try { ... } catch { /* best-effort * / }` pattern
|
|
43
|
+
* so every partial failure surfaces with structured context. Operations
|
|
44
|
+
* are individually idempotent (move-to-same-column is a no-op, add/remove
|
|
45
|
+
* label checks existing state) so re-running a partially-succeeded
|
|
46
|
+
* transition is safe.
|
|
47
|
+
*/
|
|
48
|
+
export async function runTransition(client, card, plan, opts = {}) {
|
|
49
|
+
const attempts = opts.retries ?? 3;
|
|
50
|
+
const backoffMs = opts.backoffMs ?? 500;
|
|
51
|
+
const shortId = card.short_id;
|
|
52
|
+
// Fetch the board once so we can resolve columns and labels together.
|
|
53
|
+
// Mutations to card state (move, label add/remove) all invalidate the
|
|
54
|
+
// same board-level data anyway, so one read is enough.
|
|
55
|
+
const board = (await withRetry("move", shortId, () => client.getBoard(card.project_id), attempts, backoffMs));
|
|
56
|
+
const columns = board.columns;
|
|
57
|
+
const labels = board.labels ?? [];
|
|
58
|
+
// --- 1. MOVE ---
|
|
59
|
+
if (plan.move) {
|
|
60
|
+
const target = columns.find((c) => c.name.toLowerCase() === plan.move.columnName.toLowerCase());
|
|
61
|
+
if (!target) {
|
|
62
|
+
const msg = `column "${plan.move.columnName}" not found`;
|
|
63
|
+
if (opts.strictColumn) {
|
|
64
|
+
throw new TransitionError("move", 1, msg);
|
|
65
|
+
}
|
|
66
|
+
log.warn(TAG, `#${shortId}: ${msg} — skipping move`);
|
|
67
|
+
}
|
|
68
|
+
else if (card.column_id !== target.id) {
|
|
69
|
+
await withRetry("move", shortId, () => client.moveCard(card.id, target.id), attempts, backoffMs);
|
|
70
|
+
log.info(TAG, `#${shortId} → "${target.name}"`);
|
|
71
|
+
card.column_id = target.id;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// --- 2. ADD LABELS ---
|
|
75
|
+
if (plan.addLabels?.length) {
|
|
76
|
+
const existing = new Set(card.labelIds ?? []);
|
|
77
|
+
for (const { name, color } of plan.addLabels) {
|
|
78
|
+
const match = labels.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
|
79
|
+
const labelId = match?.id ??
|
|
80
|
+
(await ensureLabel(client, card.project_id, name, color, attempts, backoffMs));
|
|
81
|
+
if (!labelId || existing.has(labelId))
|
|
82
|
+
continue;
|
|
83
|
+
await withRetry("addLabel", shortId, () => client.addLabelToCard(card.id, labelId), attempts, backoffMs);
|
|
84
|
+
existing.add(labelId);
|
|
85
|
+
log.info(TAG, `#${shortId} +label "${name}"`);
|
|
86
|
+
}
|
|
87
|
+
card.labelIds = Array.from(existing);
|
|
88
|
+
}
|
|
89
|
+
// --- 3. REMOVE LABELS ---
|
|
90
|
+
if (plan.removeLabels?.length) {
|
|
91
|
+
const existing = new Set(card.labelIds ?? []);
|
|
92
|
+
for (const name of plan.removeLabels) {
|
|
93
|
+
const match = labels.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
|
94
|
+
if (!match || !existing.has(match.id))
|
|
95
|
+
continue;
|
|
96
|
+
await withRetry("removeLabel", shortId, () => client.removeLabelFromCard(card.id, match.id), attempts, backoffMs);
|
|
97
|
+
existing.delete(match.id);
|
|
98
|
+
log.info(TAG, `#${shortId} -label "${name}"`);
|
|
99
|
+
}
|
|
100
|
+
card.labelIds = Array.from(existing);
|
|
101
|
+
}
|
|
102
|
+
// --- 4. UPDATE CARD ---
|
|
103
|
+
if (plan.updateCard) {
|
|
104
|
+
await withRetry("updateCard", shortId, () => client.updateCard(card.id, plan.updateCard), attempts, backoffMs);
|
|
105
|
+
log.info(TAG, `#${shortId} updated`);
|
|
106
|
+
}
|
|
107
|
+
// --- 5. END SESSION ---
|
|
108
|
+
if (plan.endSession) {
|
|
109
|
+
await withRetry("endSession", shortId, () => client.endAgentSession(card.id, plan.endSession), attempts, backoffMs);
|
|
110
|
+
log.info(TAG, `#${shortId} session ended (${plan.endSession.status})`);
|
|
111
|
+
}
|
|
112
|
+
// --- Audit trail ---
|
|
113
|
+
if (opts.store && opts.runId) {
|
|
114
|
+
try {
|
|
115
|
+
await opts.store.heartbeat(opts.runId);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// non-fatal
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function ensureLabel(client, projectId, name, color, attempts, backoffMs) {
|
|
123
|
+
try {
|
|
124
|
+
const result = await withRetry("addLabel", 0, () => client.createLabel(projectId, { name, color: color ?? "#8b5cf6" }), attempts, backoffMs);
|
|
125
|
+
return result?.label?.id ?? null;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
log.warn(TAG, `ensureLabel "${name}" failed: ${err instanceof Error ? err.message : err}`);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -48,6 +48,29 @@ export interface AgentConfig {
|
|
|
48
48
|
mergedLabel: string;
|
|
49
49
|
mergedLabelColor: string;
|
|
50
50
|
};
|
|
51
|
+
budget: {
|
|
52
|
+
/** Max implement attempts per card before DLQ (reset on success). */
|
|
53
|
+
maxAttemptsPerCard: number;
|
|
54
|
+
/** Max cumulative spend per card, in cents, before DLQ. */
|
|
55
|
+
maxCentsPerCard: number;
|
|
56
|
+
/** Daily spend cap, in cents (UTC day). Exceeded → pause pickups. */
|
|
57
|
+
dailyBudgetCents: number;
|
|
58
|
+
/** Label applied to DLQ'd cards. */
|
|
59
|
+
dlqLabel: string;
|
|
60
|
+
dlqLabelColor: string;
|
|
61
|
+
};
|
|
62
|
+
http: {
|
|
63
|
+
/** Local HTTP status/control server. Bound to 127.0.0.1 by default. */
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
port: number;
|
|
66
|
+
bindAddr: string;
|
|
67
|
+
};
|
|
68
|
+
timing: {
|
|
69
|
+
heartbeatMs: number;
|
|
70
|
+
staleHeartbeatMs: number;
|
|
71
|
+
reconcileIntervalMs: number;
|
|
72
|
+
worktreeGcIntervalMs: number;
|
|
73
|
+
};
|
|
51
74
|
}
|
|
52
75
|
export declare const DEFAULT_AGENT_CONFIG: AgentConfig;
|
|
53
76
|
export declare const NEED_REVIEW_LABEL = "Need Review";
|
package/dist/types.js
CHANGED
|
@@ -28,7 +28,7 @@ export const DEFAULT_AGENT_CONFIG = {
|
|
|
28
28
|
deepReview: false,
|
|
29
29
|
devServerBasePort: 4200,
|
|
30
30
|
timeout: 120_000,
|
|
31
|
-
failColumn: "
|
|
31
|
+
failColumn: "To Do",
|
|
32
32
|
},
|
|
33
33
|
review: {
|
|
34
34
|
enabled: true,
|
|
@@ -46,6 +46,24 @@ export const DEFAULT_AGENT_CONFIG = {
|
|
|
46
46
|
mergedLabel: "Merged",
|
|
47
47
|
mergedLabelColor: "#6366f1",
|
|
48
48
|
},
|
|
49
|
+
budget: {
|
|
50
|
+
maxAttemptsPerCard: 3,
|
|
51
|
+
maxCentsPerCard: 500, // $5.00
|
|
52
|
+
dailyBudgetCents: 5000, // $50.00
|
|
53
|
+
dlqLabel: "dlq",
|
|
54
|
+
dlqLabelColor: "#dc2626",
|
|
55
|
+
},
|
|
56
|
+
http: {
|
|
57
|
+
enabled: true,
|
|
58
|
+
port: 47821,
|
|
59
|
+
bindAddr: "127.0.0.1",
|
|
60
|
+
},
|
|
61
|
+
timing: {
|
|
62
|
+
heartbeatMs: 30_000,
|
|
63
|
+
staleHeartbeatMs: 120_000,
|
|
64
|
+
reconcileIntervalMs: 60_000,
|
|
65
|
+
worktreeGcIntervalMs: 5 * 60_000,
|
|
66
|
+
},
|
|
49
67
|
};
|
|
50
68
|
// ============ LABELS ============
|
|
51
69
|
export const NEED_REVIEW_LABEL = "Need Review";
|
package/dist/verification.d.ts
CHANGED
|
@@ -13,4 +13,21 @@ export declare function runLint(worktreePath: string, timeout: number): string[]
|
|
|
13
13
|
export declare function runDeepReview(worktreePath: string, config: AgentConfig, workerId: number): Promise<string[]>;
|
|
14
14
|
export declare function attemptAutoFix(worktreePath: string, config: AgentConfig, errors: string[]): void;
|
|
15
15
|
export declare function reportFindings(client: HarmonyApiClient, cardId: string, result: VerificationResult): Promise<void>;
|
|
16
|
+
export declare class DevServerReadinessError extends Error {
|
|
17
|
+
constructor(message: string);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Wait for a dev server to signal readiness on stdout/stderr.
|
|
21
|
+
*
|
|
22
|
+
* Rejects (does NOT resolve) on timeout or process error — callers that
|
|
23
|
+
* need the server to be live for correctness (e.g. the review worker)
|
|
24
|
+
* must not proceed without a confirmed signal. If the server dies before
|
|
25
|
+
* becoming ready, we reject with the exit details.
|
|
26
|
+
*/
|
|
16
27
|
export declare function waitForDevServer(proc: ChildProcess, timeout: number): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Verify the dev server actually responds to HTTP GET before treating
|
|
30
|
+
* it as ready. Listening alone isn't enough — a framework can bind but
|
|
31
|
+
* still crash on the first request.
|
|
32
|
+
*/
|
|
33
|
+
export declare function probeDevServer(port: number, timeoutMs?: number): Promise<void>;
|
package/dist/verification.js
CHANGED
|
@@ -82,8 +82,15 @@ export async function runDeepReview(worktreePath, config, workerId) {
|
|
|
82
82
|
cwd: worktreePath,
|
|
83
83
|
stdio: ["ignore", "pipe", "pipe"],
|
|
84
84
|
});
|
|
85
|
-
// Wait for dev server to be ready
|
|
86
|
-
|
|
85
|
+
// Wait for dev server to be ready, then confirm it answers HTTP.
|
|
86
|
+
try {
|
|
87
|
+
await waitForDevServer(devServer, 30_000);
|
|
88
|
+
await probeDevServer(port);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
log.error(TAG, `Dev server did not become ready: ${err instanceof Error ? err.message : err}`);
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
87
94
|
// Get diff for review context
|
|
88
95
|
let diff = "";
|
|
89
96
|
try {
|
|
@@ -217,35 +224,89 @@ function parseReviewFindings(output) {
|
|
|
217
224
|
.map((l) => l.replace(/^\d+[.)]\s*/, ""))
|
|
218
225
|
.filter((l) => l.length > 0);
|
|
219
226
|
}
|
|
227
|
+
export class DevServerReadinessError extends Error {
|
|
228
|
+
constructor(message) {
|
|
229
|
+
super(message);
|
|
230
|
+
this.name = "DevServerReadinessError";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Wait for a dev server to signal readiness on stdout/stderr.
|
|
235
|
+
*
|
|
236
|
+
* Rejects (does NOT resolve) on timeout or process error — callers that
|
|
237
|
+
* need the server to be live for correctness (e.g. the review worker)
|
|
238
|
+
* must not proceed without a confirmed signal. If the server dies before
|
|
239
|
+
* becoming ready, we reject with the exit details.
|
|
240
|
+
*/
|
|
220
241
|
export function waitForDevServer(proc, timeout) {
|
|
221
242
|
return new Promise((resolve, reject) => {
|
|
243
|
+
let settled = false;
|
|
222
244
|
const cleanup = () => {
|
|
223
245
|
proc.stdout?.off("data", onData);
|
|
224
246
|
proc.stderr?.off("data", onData);
|
|
225
247
|
proc.off("error", onError);
|
|
248
|
+
proc.off("exit", onExit);
|
|
249
|
+
clearTimeout(timer);
|
|
226
250
|
};
|
|
227
|
-
const
|
|
228
|
-
|
|
251
|
+
const settleResolve = () => {
|
|
252
|
+
if (settled)
|
|
253
|
+
return;
|
|
254
|
+
settled = true;
|
|
229
255
|
cleanup();
|
|
230
256
|
resolve();
|
|
257
|
+
};
|
|
258
|
+
const settleReject = (err) => {
|
|
259
|
+
if (settled)
|
|
260
|
+
return;
|
|
261
|
+
settled = true;
|
|
262
|
+
cleanup();
|
|
263
|
+
reject(err);
|
|
264
|
+
};
|
|
265
|
+
const timer = setTimeout(() => {
|
|
266
|
+
settleReject(new DevServerReadinessError(`dev server did not signal readiness within ${timeout}ms`));
|
|
231
267
|
}, timeout);
|
|
232
268
|
const onData = (data) => {
|
|
233
269
|
const text = data.toString();
|
|
234
270
|
if (text.includes("ready") ||
|
|
235
271
|
text.includes("localhost") ||
|
|
236
272
|
text.includes("Local:")) {
|
|
237
|
-
|
|
238
|
-
cleanup();
|
|
239
|
-
resolve();
|
|
273
|
+
settleResolve();
|
|
240
274
|
}
|
|
241
275
|
};
|
|
242
276
|
const onError = (err) => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
277
|
+
settleReject(err);
|
|
278
|
+
};
|
|
279
|
+
const onExit = (code, signal) => {
|
|
280
|
+
settleReject(new DevServerReadinessError(`dev server exited before becoming ready (code=${code ?? "?"}, signal=${signal ?? "?"})`));
|
|
246
281
|
};
|
|
247
282
|
proc.stdout?.on("data", onData);
|
|
248
283
|
proc.stderr?.on("data", onData);
|
|
249
284
|
proc.on("error", onError);
|
|
285
|
+
proc.on("exit", onExit);
|
|
250
286
|
});
|
|
251
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Verify the dev server actually responds to HTTP GET before treating
|
|
290
|
+
* it as ready. Listening alone isn't enough — a framework can bind but
|
|
291
|
+
* still crash on the first request.
|
|
292
|
+
*/
|
|
293
|
+
export async function probeDevServer(port, timeoutMs = 5000) {
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
296
|
+
try {
|
|
297
|
+
const res = await fetch(`http://localhost:${port}/`, {
|
|
298
|
+
signal: controller.signal,
|
|
299
|
+
});
|
|
300
|
+
if (!res.ok && res.status >= 500) {
|
|
301
|
+
throw new DevServerReadinessError(`dev server returned ${res.status} on probe`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (err instanceof DevServerReadinessError)
|
|
306
|
+
throw err;
|
|
307
|
+
throw new DevServerReadinessError(`dev server probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
}
|
|
312
|
+
}
|
package/dist/watcher.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export declare class Watcher {
|
|
|
23
23
|
private presenceChannel;
|
|
24
24
|
private supabase;
|
|
25
25
|
private daemonId;
|
|
26
|
+
private connected;
|
|
27
|
+
get isConnected(): boolean;
|
|
26
28
|
constructor(credentials: RealtimeCredentials, projectId: string, onCardBroadcast: CardBroadcastHandler, onAgentCommand?: AgentCommandHandler | undefined);
|
|
27
29
|
start(): Promise<void>;
|
|
28
30
|
stop(): Promise<void>;
|
package/dist/watcher.js
CHANGED
|
@@ -16,6 +16,10 @@ export class Watcher {
|
|
|
16
16
|
presenceChannel = null;
|
|
17
17
|
supabase = null;
|
|
18
18
|
daemonId = randomUUID();
|
|
19
|
+
connected = false;
|
|
20
|
+
get isConnected() {
|
|
21
|
+
return this.connected;
|
|
22
|
+
}
|
|
19
23
|
constructor(credentials, projectId, onCardBroadcast, onAgentCommand) {
|
|
20
24
|
this.credentials = credentials;
|
|
21
25
|
this.projectId = projectId;
|
|
@@ -55,14 +59,20 @@ export class Watcher {
|
|
|
55
59
|
})
|
|
56
60
|
.subscribe((status) => {
|
|
57
61
|
if (status === "SUBSCRIBED") {
|
|
62
|
+
this.connected = true;
|
|
58
63
|
log.info(TAG, "Broadcast subscription active");
|
|
59
64
|
}
|
|
60
65
|
else if (status === "CHANNEL_ERROR") {
|
|
66
|
+
this.connected = false;
|
|
61
67
|
log.error(TAG, "Broadcast channel error — will rely on reconciliation");
|
|
62
68
|
}
|
|
63
69
|
else if (status === "TIMED_OUT") {
|
|
70
|
+
this.connected = false;
|
|
64
71
|
log.warn(TAG, "Broadcast subscription timed out — retrying...");
|
|
65
72
|
}
|
|
73
|
+
else if (status === "CLOSED") {
|
|
74
|
+
this.connected = false;
|
|
75
|
+
}
|
|
66
76
|
});
|
|
67
77
|
this.channel = channel;
|
|
68
78
|
// Subscribe presence channel for daemon online indicator
|
|
@@ -94,6 +104,7 @@ export class Watcher {
|
|
|
94
104
|
await this.supabase.realtime.disconnect();
|
|
95
105
|
this.supabase = null;
|
|
96
106
|
}
|
|
107
|
+
this.connected = false;
|
|
97
108
|
log.info(TAG, "Broadcast subscription stopped");
|
|
98
109
|
}
|
|
99
110
|
}
|