@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
@@ -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: "Needs Fix",
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";
@@ -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>;
@@ -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
- await waitForDevServer(devServer, 30_000);
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 timer = setTimeout(() => {
228
- log.warn(TAG, "Dev server readiness not detected, proceeding anyway");
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
- clearTimeout(timer);
238
- cleanup();
239
- resolve();
273
+ settleResolve();
240
274
  }
241
275
  };
242
276
  const onError = (err) => {
243
- clearTimeout(timer);
244
- cleanup();
245
- reject(err);
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
  }