@gethmy/agent 1.9.1 → 1.10.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/dist/cli.js +747 -109
- package/dist/index.js +742 -108
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -220,12 +220,170 @@ var init_board_helpers = __esm(() => {
|
|
|
220
220
|
init_log();
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
// src/plan-phase.ts
|
|
224
|
+
function scoreComplexity(enriched) {
|
|
225
|
+
const { card, labels, subtasks } = enriched;
|
|
226
|
+
let score = 0;
|
|
227
|
+
const desc = (card.description ?? "").trim();
|
|
228
|
+
if (desc.length > 600)
|
|
229
|
+
score += 3;
|
|
230
|
+
else if (desc.length > 200)
|
|
231
|
+
score += 2;
|
|
232
|
+
else if (desc.length > 0)
|
|
233
|
+
score += 1;
|
|
234
|
+
score += Math.min(subtasks.length, 4);
|
|
235
|
+
const names = labels.map((l) => l.name.toLowerCase());
|
|
236
|
+
if (names.some((n) => /feature|epic|refactor|architecture|migration/.test(n))) {
|
|
237
|
+
score += 2;
|
|
238
|
+
}
|
|
239
|
+
if (names.some((n) => /typo|chore|trivial|docs/.test(n))) {
|
|
240
|
+
score -= 2;
|
|
241
|
+
}
|
|
242
|
+
return Math.max(0, score);
|
|
243
|
+
}
|
|
244
|
+
function shouldPlan(enriched, config) {
|
|
245
|
+
if (!config.enabled)
|
|
246
|
+
return false;
|
|
247
|
+
const { card } = enriched;
|
|
248
|
+
const hasPlan = !!card.plan_id;
|
|
249
|
+
const needsRefresh = card.needs_plan_refresh === true;
|
|
250
|
+
if (hasPlan && !needsRefresh)
|
|
251
|
+
return false;
|
|
252
|
+
return scoreComplexity(enriched) >= config.minComplexityScore;
|
|
253
|
+
}
|
|
254
|
+
function buildPlanPrompt(enriched, worktreePath) {
|
|
255
|
+
const { card, column, labels, subtasks } = enriched;
|
|
256
|
+
const labelStr = labels.length > 0 ? labels.map((l) => l.name).join(", ") : "none";
|
|
257
|
+
const subtaskStr = subtasks.length > 0 ? subtasks.map((s) => `- ${s.title}`).join(`
|
|
258
|
+
`) : "No subtasks defined.";
|
|
259
|
+
const description = card.description?.trim() || "No description provided.";
|
|
260
|
+
return `You are a senior engineer producing an IMPLEMENTATION PLAN for a task on the Harmony board. You are in PLAN MODE: explore the codebase to ground the plan, but do NOT write, edit, or commit any code in this pass.
|
|
261
|
+
|
|
262
|
+
## Card: #${card.short_id} - ${card.title}
|
|
263
|
+
**Labels**: ${labelStr}
|
|
264
|
+
**Column**: ${column.name}
|
|
265
|
+
**Priority**: ${card.priority}
|
|
266
|
+
|
|
267
|
+
## Description
|
|
268
|
+
${description}
|
|
269
|
+
|
|
270
|
+
## Subtasks
|
|
271
|
+
${subtaskStr}
|
|
272
|
+
|
|
273
|
+
## Your job
|
|
274
|
+
1. Read the parts of the codebase relevant to this task (use Read/Grep/Glob; do NOT edit).
|
|
275
|
+
2. Decide the smallest correct approach. Note the exact files you expect to touch.
|
|
276
|
+
3. Call out risks, unknowns, and anything that needs a human decision.
|
|
277
|
+
4. Break the work into ordered, independently-verifiable tasks.
|
|
278
|
+
|
|
279
|
+
You are exploring the worktree at \`${worktreePath}\`. Read-only this pass — no Write/Edit/Bash-that-mutates, no commits.
|
|
280
|
+
|
|
281
|
+
## Output contract
|
|
282
|
+
End your final message with EXACTLY ONE fenced block tagged \`plan\`, in this structure:
|
|
283
|
+
|
|
284
|
+
\`\`\`plan
|
|
285
|
+
# <one-line plan title>
|
|
286
|
+
|
|
287
|
+
## Approach
|
|
288
|
+
<2-4 sentences: the chosen approach and why>
|
|
289
|
+
|
|
290
|
+
## Files
|
|
291
|
+
- <path> — <what changes here>
|
|
292
|
+
|
|
293
|
+
## Steps
|
|
294
|
+
1. <ordered step>
|
|
295
|
+
2. <ordered step>
|
|
296
|
+
|
|
297
|
+
## Tasks
|
|
298
|
+
- [ ] <discrete, verifiable task>
|
|
299
|
+
- [ ] <discrete, verifiable task>
|
|
300
|
+
|
|
301
|
+
## Risks
|
|
302
|
+
- <risk / unknown / decision needed, or "none">
|
|
303
|
+
\`\`\`
|
|
304
|
+
|
|
305
|
+
The \`## Tasks\` checklist is parsed into trackable tasks, so keep each line a single concrete action.`;
|
|
306
|
+
}
|
|
307
|
+
function extractPlanArtifact(assistantText, fallbackTitle) {
|
|
308
|
+
const text = assistantText ?? "";
|
|
309
|
+
const fenced = text.match(PLAN_FENCE);
|
|
310
|
+
const markdown = (fenced ? fenced[1] : text).trim();
|
|
311
|
+
const titleMatch = markdown.match(H1);
|
|
312
|
+
const title = (titleMatch?.[1] ?? fallbackTitle).trim() || fallbackTitle;
|
|
313
|
+
return {
|
|
314
|
+
title,
|
|
315
|
+
markdown,
|
|
316
|
+
tasks: parseTasksSection(markdown)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function parseTasksSection(markdown) {
|
|
320
|
+
const lines = markdown.split(`
|
|
321
|
+
`);
|
|
322
|
+
const tasks = [];
|
|
323
|
+
let inTasks = false;
|
|
324
|
+
for (const line of lines) {
|
|
325
|
+
const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
326
|
+
if (heading) {
|
|
327
|
+
inTasks = /^tasks\b/i.test(heading[1].trim());
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!inTasks)
|
|
331
|
+
continue;
|
|
332
|
+
const item = line.match(TASK_LINE);
|
|
333
|
+
if (item) {
|
|
334
|
+
const content = item[1].trim();
|
|
335
|
+
if (content)
|
|
336
|
+
tasks.push({ content });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return tasks;
|
|
340
|
+
}
|
|
341
|
+
function buildPlanComment(artifact) {
|
|
342
|
+
const body = artifact.markdown.trim();
|
|
343
|
+
return [
|
|
344
|
+
"## \uD83E\uDDED Plan (agent, advisory)",
|
|
345
|
+
"",
|
|
346
|
+
"The daemon explored the worktree read-only and produced this plan before implementing. Implementation is starting now in the same run.",
|
|
347
|
+
"",
|
|
348
|
+
body
|
|
349
|
+
].join(`
|
|
350
|
+
`);
|
|
351
|
+
}
|
|
352
|
+
function buildGatedPlanComment(artifact, pickupColumnName) {
|
|
353
|
+
const body = artifact.markdown.trim();
|
|
354
|
+
return [
|
|
355
|
+
"## \uD83E\uDDED Plan (agent, awaiting approval)",
|
|
356
|
+
"",
|
|
357
|
+
`The daemon explored the worktree read-only and produced this plan. Implementation is **gated on your approval** — review the plan below, then move this card to **${pickupColumnName}** to start implementation with it. Edit the linked plan first if the approach needs changes.`,
|
|
358
|
+
"",
|
|
359
|
+
body
|
|
360
|
+
].join(`
|
|
361
|
+
`);
|
|
362
|
+
}
|
|
363
|
+
var DEFAULT_PLANNING_CONFIG, PLAN_FENCE, H1, TASK_LINE;
|
|
364
|
+
var init_plan_phase = __esm(() => {
|
|
365
|
+
DEFAULT_PLANNING_CONFIG = {
|
|
366
|
+
enabled: false,
|
|
367
|
+
mode: "advisory",
|
|
368
|
+
model: "sonnet",
|
|
369
|
+
maxTurns: 40,
|
|
370
|
+
postComment: true,
|
|
371
|
+
awaitingApprovalColumn: "To Do",
|
|
372
|
+
minComplexityScore: 3,
|
|
373
|
+
approvalTtlHours: 0
|
|
374
|
+
};
|
|
375
|
+
PLAN_FENCE = /```plan\s*\n([\s\S]*?)```/i;
|
|
376
|
+
H1 = /^#\s+(.+?)\s*$/m;
|
|
377
|
+
TASK_LINE = /^\s*(?:[-*]\s*\[[ xX]?\]|[-*]|\d+[.)])\s+(.+?)\s*$/;
|
|
378
|
+
});
|
|
379
|
+
|
|
223
380
|
// src/types.ts
|
|
224
381
|
function agentIdentifier(workerId) {
|
|
225
382
|
return `harmony-daemon-${workerId}`;
|
|
226
383
|
}
|
|
227
|
-
var DEFAULT_AGENT_CONFIG, NEED_REVIEW_LABEL = "Need Review", NEED_REVIEW_LABEL_COLOR = "#f59e0b", AGENT_NAME = "Harmony Agent";
|
|
384
|
+
var DEFAULT_AGENT_CONFIG, IN_PROGRESS_COLUMN = "In Progress", NEED_REVIEW_LABEL = "Need Review", NEED_REVIEW_LABEL_COLOR = "#f59e0b", AGENT_NAME = "Harmony Agent";
|
|
228
385
|
var init_types = __esm(() => {
|
|
386
|
+
init_plan_phase();
|
|
229
387
|
DEFAULT_AGENT_CONFIG = {
|
|
230
388
|
poolSize: 3,
|
|
231
389
|
maxTimeout: 1800000,
|
|
@@ -292,7 +450,8 @@ var init_types = __esm(() => {
|
|
|
292
450
|
staleHeartbeatMs: 120000,
|
|
293
451
|
reconcileIntervalMs: 60000,
|
|
294
452
|
worktreeGcIntervalMs: 5 * 60000
|
|
295
|
-
}
|
|
453
|
+
},
|
|
454
|
+
planning: DEFAULT_PLANNING_CONFIG
|
|
296
455
|
};
|
|
297
456
|
});
|
|
298
457
|
|
|
@@ -388,6 +547,10 @@ function loadDaemonConfig() {
|
|
|
388
547
|
timing: {
|
|
389
548
|
...DEFAULT_AGENT_CONFIG.timing,
|
|
390
549
|
...agentOverrides.timing ?? {}
|
|
550
|
+
},
|
|
551
|
+
planning: {
|
|
552
|
+
...DEFAULT_AGENT_CONFIG.planning,
|
|
553
|
+
...agentOverrides.planning ?? {}
|
|
391
554
|
}
|
|
392
555
|
};
|
|
393
556
|
return {
|
|
@@ -447,6 +610,20 @@ async function validateColumnReferences(client, projectId, config) {
|
|
|
447
610
|
}
|
|
448
611
|
required.push({ value: config.review.moveToColumn, where: "review.moveToColumn" }, { value: config.review.failColumn, where: "review.failColumn" });
|
|
449
612
|
}
|
|
613
|
+
if (config.planning.enabled && config.planning.mode === "gated") {
|
|
614
|
+
required.push({
|
|
615
|
+
value: config.planning.awaitingApprovalColumn,
|
|
616
|
+
where: "planning.awaitingApprovalColumn"
|
|
617
|
+
});
|
|
618
|
+
const parkCol = config.planning.awaitingApprovalColumn?.toLowerCase();
|
|
619
|
+
const allPickups = [
|
|
620
|
+
...config.pickupColumns,
|
|
621
|
+
...config.review.enabled ? config.review.pickupColumns : []
|
|
622
|
+
];
|
|
623
|
+
if (parkCol && allPickups.some((c) => c.toLowerCase() === parkCol)) {
|
|
624
|
+
issues.push(`planning.awaitingApprovalColumn: "${config.planning.awaitingApprovalColumn}" is also a pickup column (implement or review) — a gated card parked there is picked up immediately, bypassing approval. Use a column the daemon does not pick up from.`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
450
627
|
for (const { value, where } of required) {
|
|
451
628
|
if (!value)
|
|
452
629
|
continue;
|
|
@@ -841,24 +1018,63 @@ import {
|
|
|
841
1018
|
class HttpServer {
|
|
842
1019
|
opts;
|
|
843
1020
|
server = null;
|
|
1021
|
+
boundPort = null;
|
|
844
1022
|
constructor(opts) {
|
|
845
1023
|
this.opts = opts;
|
|
846
1024
|
}
|
|
1025
|
+
get port() {
|
|
1026
|
+
return this.boundPort;
|
|
1027
|
+
}
|
|
847
1028
|
async start() {
|
|
848
|
-
|
|
849
|
-
this.
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
}
|
|
856
|
-
});
|
|
1029
|
+
this.server = createServer((req, res) => {
|
|
1030
|
+
this.route(req, res).catch((err) => {
|
|
1031
|
+
log.error(TAG3, `unhandled: ${err instanceof Error ? err.message : err}`);
|
|
1032
|
+
if (!res.headersSent) {
|
|
1033
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
1034
|
+
res.end(JSON.stringify({ error: "internal_error" }));
|
|
1035
|
+
}
|
|
857
1036
|
});
|
|
858
|
-
|
|
859
|
-
|
|
1037
|
+
});
|
|
1038
|
+
const attempts = Math.max(1, this.opts.maxPortAttempts ?? 10);
|
|
1039
|
+
const startPort = this.opts.port;
|
|
1040
|
+
for (let i = 0;i < attempts; i++) {
|
|
1041
|
+
const port = startPort + i;
|
|
1042
|
+
try {
|
|
1043
|
+
await this.listenOnce(port);
|
|
1044
|
+
this.boundPort = port;
|
|
1045
|
+
if (port !== startPort) {
|
|
1046
|
+
log.info(TAG3, `port ${startPort} busy — bound to ${port} instead`);
|
|
1047
|
+
}
|
|
1048
|
+
return port;
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
const lastAttempt = i === attempts - 1;
|
|
1051
|
+
if (isAddrInUse(err) && !lastAttempt) {
|
|
1052
|
+
log.debug(TAG3, `port ${port} in use, trying ${port + 1}`);
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
throw err;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
throw new Error("HTTP server failed to bind");
|
|
1059
|
+
}
|
|
1060
|
+
listenOnce(port) {
|
|
1061
|
+
return new Promise((resolve, reject) => {
|
|
1062
|
+
const server = this.server;
|
|
1063
|
+
if (!server) {
|
|
1064
|
+
reject(new Error("server not created"));
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const onError = (err) => {
|
|
1068
|
+
server.removeListener("listening", onListening);
|
|
1069
|
+
reject(err);
|
|
1070
|
+
};
|
|
1071
|
+
const onListening = () => {
|
|
1072
|
+
server.removeListener("error", onError);
|
|
860
1073
|
resolve();
|
|
861
|
-
}
|
|
1074
|
+
};
|
|
1075
|
+
server.once("error", onError);
|
|
1076
|
+
server.once("listening", onListening);
|
|
1077
|
+
server.listen(port, this.opts.bindAddr);
|
|
862
1078
|
});
|
|
863
1079
|
}
|
|
864
1080
|
async stop() {
|
|
@@ -868,6 +1084,7 @@ class HttpServer {
|
|
|
868
1084
|
this.server?.close(() => resolve());
|
|
869
1085
|
});
|
|
870
1086
|
this.server = null;
|
|
1087
|
+
this.boundPort = null;
|
|
871
1088
|
}
|
|
872
1089
|
async route(req, res) {
|
|
873
1090
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -914,6 +1131,9 @@ class HttpServer {
|
|
|
914
1131
|
}
|
|
915
1132
|
}
|
|
916
1133
|
}
|
|
1134
|
+
function isAddrInUse(err) {
|
|
1135
|
+
return typeof err === "object" && err !== null && err.code === "EADDRINUSE";
|
|
1136
|
+
}
|
|
917
1137
|
function parseCommand(path) {
|
|
918
1138
|
const match = path.match(/^\/(pause|resume|stop)\/([^/]+)$/);
|
|
919
1139
|
if (!match)
|
|
@@ -1374,6 +1594,62 @@ function formatCents(cents) {
|
|
|
1374
1594
|
return `$${(cents / 100).toFixed(2)}`;
|
|
1375
1595
|
}
|
|
1376
1596
|
|
|
1597
|
+
// src/error-classifier.ts
|
|
1598
|
+
function parseRetryAfterMs(message) {
|
|
1599
|
+
const match = message.match(/retry[- ]?after["':\s]+(\d+)/i);
|
|
1600
|
+
if (!match)
|
|
1601
|
+
return;
|
|
1602
|
+
const seconds = Number(match[1]);
|
|
1603
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
1604
|
+
return;
|
|
1605
|
+
return seconds * 1000;
|
|
1606
|
+
}
|
|
1607
|
+
function classifyRunError(message) {
|
|
1608
|
+
if (!message)
|
|
1609
|
+
return { kind: null };
|
|
1610
|
+
const retryAfterMs = parseRetryAfterMs(message);
|
|
1611
|
+
if (AUTH.test(message))
|
|
1612
|
+
return { kind: "auth", retryAfterMs };
|
|
1613
|
+
if (OUT_OF_CREDITS.test(message))
|
|
1614
|
+
return { kind: "out_of_credits", retryAfterMs };
|
|
1615
|
+
if (USAGE_LIMIT.test(message))
|
|
1616
|
+
return { kind: "usage_limit", retryAfterMs };
|
|
1617
|
+
if (RATE_LIMIT.test(message))
|
|
1618
|
+
return { kind: "rate_limit", retryAfterMs };
|
|
1619
|
+
return { kind: null };
|
|
1620
|
+
}
|
|
1621
|
+
function describeApiError(kind) {
|
|
1622
|
+
switch (kind) {
|
|
1623
|
+
case "auth":
|
|
1624
|
+
return "Anthropic auth error — agent paused, check API credentials";
|
|
1625
|
+
case "out_of_credits":
|
|
1626
|
+
return "Anthropic credit balance too low — retrying after top-up";
|
|
1627
|
+
case "usage_limit":
|
|
1628
|
+
return "Anthropic usage limit reached — retrying after reset";
|
|
1629
|
+
case "rate_limit":
|
|
1630
|
+
return "Anthropic rate limit hit — retrying shortly";
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
function cooldownMsFor(kind) {
|
|
1634
|
+
switch (kind) {
|
|
1635
|
+
case "rate_limit":
|
|
1636
|
+
return 60000;
|
|
1637
|
+
case "usage_limit":
|
|
1638
|
+
return 15 * 60000;
|
|
1639
|
+
case "out_of_credits":
|
|
1640
|
+
return 30 * 60000;
|
|
1641
|
+
case "auth":
|
|
1642
|
+
return 30 * 60000;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
var AUTH, OUT_OF_CREDITS, USAGE_LIMIT, RATE_LIMIT;
|
|
1646
|
+
var init_error_classifier = __esm(() => {
|
|
1647
|
+
AUTH = /\b401\b|invalid x-api-key|authentication_error|unauthorized|oauth token (?:has )?expired|please run .*login|invalid bearer token/i;
|
|
1648
|
+
OUT_OF_CREDITS = /\b402\b|credit balance is too low|insufficient (?:funds|credit|balance)|billing|payment required|purchase more credits/i;
|
|
1649
|
+
USAGE_LIMIT = /usage limit|daily limit|monthly limit|quota (?:exceeded|reached)|reached your .{0,20}limit|usage_limit_reached|limit will reset/i;
|
|
1650
|
+
RATE_LIMIT = /\b429\b|\b529\b|rate[ _-]?limit|too many requests|overloaded_error|"type"\s*:\s*"overloaded"/i;
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1377
1653
|
// src/queue.ts
|
|
1378
1654
|
class PriorityQueue {
|
|
1379
1655
|
config;
|
|
@@ -2386,13 +2662,15 @@ class ProgressTracker {
|
|
|
2386
2662
|
sessionId = null;
|
|
2387
2663
|
lastAssistantText = "";
|
|
2388
2664
|
assistantTextBlocks = [];
|
|
2389
|
-
constructor(client, cardId, workerId, subtasks) {
|
|
2665
|
+
constructor(client, cardId, workerId, subtasks, initialPhase = "exploring") {
|
|
2390
2666
|
this.client = client;
|
|
2391
2667
|
this.cardId = cardId;
|
|
2392
2668
|
this.workerId = workerId;
|
|
2393
2669
|
this.subtaskTotal = subtasks.length;
|
|
2394
2670
|
this.subtaskCompleted = subtasks.filter((s) => s.completed).length;
|
|
2395
2671
|
this.subtaskMode = subtasks.length > 0;
|
|
2672
|
+
this.phase = initialPhase;
|
|
2673
|
+
this.progress = PHASES[initialPhase].min;
|
|
2396
2674
|
}
|
|
2397
2675
|
setSessionId(id) {
|
|
2398
2676
|
this.sessionId = id;
|
|
@@ -2720,6 +2998,7 @@ var init_progress_tracker = __esm(() => {
|
|
|
2720
2998
|
GIT_COMMIT_RE = /\bgit\s+commit\b/;
|
|
2721
2999
|
BUILD_CMD_RE = /\b(test|build|lint|check|tsc|vitest|jest|(?:bun|npm|pnpm|yarn) run (?:build|lint))\b/;
|
|
2722
3000
|
PHASES = {
|
|
3001
|
+
planning: { min: 5, max: 20, label: "Planning" },
|
|
2723
3002
|
exploring: { min: 10, max: 25, label: "Exploring" },
|
|
2724
3003
|
implementing: { min: 25, max: 55, label: "Implementing" },
|
|
2725
3004
|
testing: { min: 55, max: 65, label: "Testing" },
|
|
@@ -2727,6 +3006,7 @@ var init_progress_tracker = __esm(() => {
|
|
|
2727
3006
|
finishing: { min: 70, max: 75, label: "Finalizing" }
|
|
2728
3007
|
};
|
|
2729
3008
|
PHASE_ORDER = {
|
|
3009
|
+
planning: -1,
|
|
2730
3010
|
exploring: 0,
|
|
2731
3011
|
implementing: 1,
|
|
2732
3012
|
testing: 2,
|
|
@@ -3032,6 +3312,14 @@ ${finding.description}${locationLine}`
|
|
|
3032
3312
|
`);
|
|
3033
3313
|
await postReviewComment(client, card, "summary", body);
|
|
3034
3314
|
}
|
|
3315
|
+
if (config.planning.enabled && card.plan_id) {
|
|
3316
|
+
try {
|
|
3317
|
+
await client.updateCard(card.id, { needsPlanRefresh: true });
|
|
3318
|
+
log.info(TAG15, `#${card.short_id} flagged needs_plan_refresh after rejected review`);
|
|
3319
|
+
} catch (err) {
|
|
3320
|
+
log.warn(TAG15, `Failed to flag needs_plan_refresh for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3035
3323
|
await moveCardToColumn(client, card, config.review.failColumn);
|
|
3036
3324
|
const failureSummary = `Review rejected (cycle ${currentCycle}/${maxCycles}): ${criticalFindings.length} critical, ${majorFindings.length} major, ${minorFindings.length} minor`;
|
|
3037
3325
|
const recoveryBranch = branchName ?? undefined;
|
|
@@ -3444,6 +3732,13 @@ class StateStore {
|
|
|
3444
3732
|
await this.persist();
|
|
3445
3733
|
return rec.attempts;
|
|
3446
3734
|
}
|
|
3735
|
+
async decrementAttempt(cardId) {
|
|
3736
|
+
const rec = this.getCard(cardId);
|
|
3737
|
+
if (!rec || rec.attempts === 0)
|
|
3738
|
+
return;
|
|
3739
|
+
rec.attempts = Math.max(0, rec.attempts - 1);
|
|
3740
|
+
await this.persist();
|
|
3741
|
+
}
|
|
3447
3742
|
async recordOutcome(cardId, outcome) {
|
|
3448
3743
|
const rec = this.ensureCard(cardId);
|
|
3449
3744
|
rec.lastOutcome = outcome;
|
|
@@ -3753,7 +4048,6 @@ var init_transitions = __esm(() => {
|
|
|
3753
4048
|
|
|
3754
4049
|
// src/review-worker.ts
|
|
3755
4050
|
import { execFileSync as execFileSync8 } from "node:child_process";
|
|
3756
|
-
import { createHash } from "node:crypto";
|
|
3757
4051
|
|
|
3758
4052
|
class ReviewWorker {
|
|
3759
4053
|
config;
|
|
@@ -3945,19 +4239,11 @@ class ReviewWorker {
|
|
|
3945
4239
|
const systemPrompt = buildReviewSystemPrompt();
|
|
3946
4240
|
const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diff, this.config.worktree.baseBranch);
|
|
3947
4241
|
try {
|
|
3948
|
-
const sessionResp = await this.client.getAgentSession(card.id);
|
|
3949
|
-
const reviewSession2 = sessionResp.session;
|
|
3950
|
-
const reviewSessionId = reviewSession2?.id ?? null;
|
|
3951
|
-
const contentHash = createHash("sha256").update(systemPrompt).digest("hex");
|
|
3952
4242
|
await this.client.recordPromptHistory({
|
|
3953
4243
|
cardId: card.id,
|
|
3954
4244
|
generatedPrompt: systemPrompt,
|
|
3955
4245
|
variant: "execute",
|
|
3956
|
-
contextIncluded: { source: "review-knowledge", mode: "review" }
|
|
3957
|
-
sessionId: reviewSessionId,
|
|
3958
|
-
contentHash,
|
|
3959
|
-
templateVersion: 1,
|
|
3960
|
-
confidence: 0.5
|
|
4246
|
+
contextIncluded: { source: "review-knowledge", mode: "review" }
|
|
3961
4247
|
});
|
|
3962
4248
|
} catch (err) {
|
|
3963
4249
|
log.warn(this.tag, `prompt_history persistence skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -4409,8 +4695,13 @@ async function promoteUnblockedSuccessors(completedCard, deps) {
|
|
|
4409
4695
|
const successorId = link.target_card.id;
|
|
4410
4696
|
try {
|
|
4411
4697
|
const { card } = await deps.client.getCard(successorId);
|
|
4412
|
-
if (card.assigned_agent_id
|
|
4413
|
-
log.
|
|
4698
|
+
if (card.assigned_agent_id === deps.agentId) {} else if (card.assigned_agent_id === null && !card.assignee_id) {
|
|
4699
|
+
log.info(TAG21, `successor #${card.short_id} unassigned — auto-assigning to continue chain`);
|
|
4700
|
+
await deps.client.updateCard(successorId, {
|
|
4701
|
+
assignedAgentId: deps.agentId
|
|
4702
|
+
});
|
|
4703
|
+
} else {
|
|
4704
|
+
log.debug(TAG21, `successor #${card.short_id} assigned to different entity — skipping`);
|
|
4414
4705
|
continue;
|
|
4415
4706
|
}
|
|
4416
4707
|
await deps.enqueue(successorId);
|
|
@@ -4692,6 +4983,7 @@ class Worker {
|
|
|
4692
4983
|
projectId;
|
|
4693
4984
|
stateStore;
|
|
4694
4985
|
onCardCompleted;
|
|
4986
|
+
onApiError;
|
|
4695
4987
|
id;
|
|
4696
4988
|
state = "idle";
|
|
4697
4989
|
cardId = null;
|
|
@@ -4708,7 +5000,7 @@ class Worker {
|
|
|
4708
5000
|
verificationFailed = false;
|
|
4709
5001
|
sessionId = null;
|
|
4710
5002
|
runId = null;
|
|
4711
|
-
constructor(id, config, client, agentId, onDone, workspaceId, projectId, stateStore, onCardCompleted) {
|
|
5003
|
+
constructor(id, config, client, agentId, onDone, workspaceId, projectId, stateStore, onCardCompleted, onApiError) {
|
|
4712
5004
|
this.config = config;
|
|
4713
5005
|
this.client = client;
|
|
4714
5006
|
this.agentId = agentId;
|
|
@@ -4717,6 +5009,7 @@ class Worker {
|
|
|
4717
5009
|
this.projectId = projectId;
|
|
4718
5010
|
this.stateStore = stateStore;
|
|
4719
5011
|
this.onCardCompleted = onCardCompleted;
|
|
5012
|
+
this.onApiError = onApiError;
|
|
4720
5013
|
this.id = id;
|
|
4721
5014
|
}
|
|
4722
5015
|
startHeartbeat() {
|
|
@@ -4757,7 +5050,7 @@ class Worker {
|
|
|
4757
5050
|
return this.state === "idle";
|
|
4758
5051
|
}
|
|
4759
5052
|
get isActive() {
|
|
4760
|
-
return this.state === "preparing" || this.state === "running" || this.state === "verifying" || this.state === "completing";
|
|
5053
|
+
return this.state === "preparing" || this.state === "planning" || this.state === "running" || this.state === "verifying" || this.state === "completing";
|
|
4761
5054
|
}
|
|
4762
5055
|
async run(card, column, labels, subtasks) {
|
|
4763
5056
|
this.aborted = false;
|
|
@@ -4803,7 +5096,7 @@ class Worker {
|
|
|
4803
5096
|
}
|
|
4804
5097
|
this.sessionId = sid;
|
|
4805
5098
|
await this.recordPhase("preparing");
|
|
4806
|
-
const moved = await moveCardAndAddLabel(this.client, card,
|
|
5099
|
+
const moved = await moveCardAndAddLabel(this.client, card, IN_PROGRESS_COLUMN, "agent");
|
|
4807
5100
|
if (!moved) {
|
|
4808
5101
|
log.warn(this.tag, `Card #${card.short_id} was NOT moved to "In Progress" — check API logs`);
|
|
4809
5102
|
}
|
|
@@ -4812,8 +5105,6 @@ class Worker {
|
|
|
4812
5105
|
this.worktreePath = createWorktree(this.config.worktree.basePath, this.config.worktree.baseBranch, this.branchName);
|
|
4813
5106
|
if (this.aborted)
|
|
4814
5107
|
return;
|
|
4815
|
-
this.state = "running";
|
|
4816
|
-
await this.recordPhase("running");
|
|
4817
5108
|
const enriched = {
|
|
4818
5109
|
card,
|
|
4819
5110
|
column,
|
|
@@ -4821,6 +5112,19 @@ class Worker {
|
|
|
4821
5112
|
subtasks,
|
|
4822
5113
|
mode: "implement"
|
|
4823
5114
|
};
|
|
5115
|
+
if (shouldPlan(enriched, this.config.planning)) {
|
|
5116
|
+
this.state = "planning";
|
|
5117
|
+
await this.recordPhase("planning");
|
|
5118
|
+
const parked = await this.runPlanningPhase(enriched);
|
|
5119
|
+
if (this.aborted)
|
|
5120
|
+
return;
|
|
5121
|
+
if (parked) {
|
|
5122
|
+
log.info(this.tag, `#${card.short_id} parked for plan approval — ending run`);
|
|
5123
|
+
return;
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
this.state = "running";
|
|
5127
|
+
await this.recordPhase("running");
|
|
4824
5128
|
const prompt = await buildPrompt(enriched, this.branchName, this.worktreePath, this.client, this.workspaceId, this.projectId);
|
|
4825
5129
|
await this.client.updateAgentProgress(card.id, {
|
|
4826
5130
|
agentIdentifier: agentIdentifier(this.id),
|
|
@@ -4859,21 +5163,46 @@ class Worker {
|
|
|
4859
5163
|
this.state = "error";
|
|
4860
5164
|
const msg = err instanceof Error ? err.message : String(err);
|
|
4861
5165
|
log.error(this.tag, `Error on #${card.short_id}: ${msg}`);
|
|
5166
|
+
const rawStderr = err?.stderr;
|
|
5167
|
+
const errClass = classifyRunError(typeof rawStderr === "string" && rawStderr ? rawStderr : msg);
|
|
5168
|
+
const apiError = errClass.kind !== null;
|
|
5169
|
+
if (apiError) {
|
|
5170
|
+
try {
|
|
5171
|
+
this.onApiError?.(errClass);
|
|
5172
|
+
} catch {}
|
|
5173
|
+
}
|
|
5174
|
+
if (this.worktreePath) {
|
|
5175
|
+
try {
|
|
5176
|
+
cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
|
|
5177
|
+
} catch {
|
|
5178
|
+
log.warn(this.tag, "Failed to cleanup worktree before requeue");
|
|
5179
|
+
}
|
|
5180
|
+
this.worktreePath = null;
|
|
5181
|
+
}
|
|
5182
|
+
const failureReason = apiError ? errClass.kind : "other";
|
|
5183
|
+
const failureSummary = apiError ? describeApiError(errClass.kind) : `Run failed: ${msg.slice(0, 300)}`;
|
|
4862
5184
|
try {
|
|
4863
5185
|
await runTransition(this.client, card, {
|
|
5186
|
+
move: { columnName: this.config.pickupColumns[0] ?? "To Do" },
|
|
4864
5187
|
endSession: {
|
|
4865
|
-
status: "
|
|
5188
|
+
status: "failed",
|
|
5189
|
+
failureReason,
|
|
5190
|
+
failureSummary,
|
|
4866
5191
|
...buildTokenPayload(this.lastSessionStats)
|
|
4867
5192
|
}
|
|
4868
5193
|
});
|
|
4869
5194
|
} catch (tErr) {
|
|
4870
|
-
log.error(this.tag, `
|
|
5195
|
+
log.error(this.tag, `error transition failed on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
|
|
4871
5196
|
}
|
|
4872
5197
|
if (this.runId) {
|
|
4873
5198
|
try {
|
|
4874
|
-
await this.stateStore.endRun(this.runId, "
|
|
5199
|
+
await this.stateStore.endRun(this.runId, "failed", errClass.kind ?? msg);
|
|
4875
5200
|
} catch {}
|
|
4876
|
-
|
|
5201
|
+
if (apiError) {
|
|
5202
|
+
await this.stateStore.decrementAttempt(card.id);
|
|
5203
|
+
} else {
|
|
5204
|
+
await this.recordOutcome(card.id, "failure");
|
|
5205
|
+
}
|
|
4877
5206
|
}
|
|
4878
5207
|
} finally {
|
|
4879
5208
|
const succeeded = this.runId && this.state !== "error" && !this.aborted && !this.verificationFailed;
|
|
@@ -5017,7 +5346,122 @@ class Worker {
|
|
|
5017
5346
|
}
|
|
5018
5347
|
}
|
|
5019
5348
|
}
|
|
5020
|
-
async
|
|
5349
|
+
async runPlanningPhase(enriched) {
|
|
5350
|
+
const planning = this.config.planning;
|
|
5351
|
+
const { card } = enriched;
|
|
5352
|
+
log.info(this.tag, `Planning pass for #${card.short_id} (mode=${planning.mode}, model=${planning.model})`);
|
|
5353
|
+
await this.client.updateAgentProgress(card.id, {
|
|
5354
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
5355
|
+
agentName: AGENT_NAME,
|
|
5356
|
+
status: "working",
|
|
5357
|
+
currentTask: "Planning approach (read-only)",
|
|
5358
|
+
progressPercent: 5,
|
|
5359
|
+
phase: "planning"
|
|
5360
|
+
}).catch(() => {});
|
|
5361
|
+
const planPrompt = buildPlanPrompt(enriched, this.worktreePath);
|
|
5362
|
+
let planTimedOut = false;
|
|
5363
|
+
const planTimeout = setTimeout(() => {
|
|
5364
|
+
planTimedOut = true;
|
|
5365
|
+
log.warn(this.tag, "Planning pass exceeded timeout — abandoning, implementing directly");
|
|
5366
|
+
if (this.process && !this.process.killed) {
|
|
5367
|
+
terminateGroup(this.process, {
|
|
5368
|
+
sigintTimeoutMs: 1e4,
|
|
5369
|
+
sigtermTimeoutMs: 5000
|
|
5370
|
+
}).catch(() => {});
|
|
5371
|
+
}
|
|
5372
|
+
}, Math.min(this.config.maxTimeout, PLAN_PHASE_TIMEOUT));
|
|
5373
|
+
try {
|
|
5374
|
+
await this.spawnClaude(planPrompt, card, [], {
|
|
5375
|
+
model: planning.model,
|
|
5376
|
+
maxTurns: planning.maxTurns,
|
|
5377
|
+
allowedTools: PLAN_ALLOWED_TOOLS,
|
|
5378
|
+
initialPhase: "planning"
|
|
5379
|
+
});
|
|
5380
|
+
} catch (err) {
|
|
5381
|
+
log.warn(this.tag, `Planning pass failed (non-fatal): ${err instanceof Error ? err.message : err}`);
|
|
5382
|
+
return false;
|
|
5383
|
+
} finally {
|
|
5384
|
+
clearTimeout(planTimeout);
|
|
5385
|
+
}
|
|
5386
|
+
if (this.aborted || planTimedOut)
|
|
5387
|
+
return false;
|
|
5388
|
+
const stats = this.lastSessionStats;
|
|
5389
|
+
if (stats?.cost) {
|
|
5390
|
+
const cents = Math.round(stats.cost.totalCostUsd * 100);
|
|
5391
|
+
if (cents > 0) {
|
|
5392
|
+
try {
|
|
5393
|
+
await this.stateStore.addCost(card.id, cents);
|
|
5394
|
+
} catch {}
|
|
5395
|
+
}
|
|
5396
|
+
}
|
|
5397
|
+
const planText = stats?.lastAssistantText ?? "";
|
|
5398
|
+
if (!planText.trim()) {
|
|
5399
|
+
log.warn(this.tag, `Planning pass for #${card.short_id} produced no text — implementing directly`);
|
|
5400
|
+
return false;
|
|
5401
|
+
}
|
|
5402
|
+
const artifact = extractPlanArtifact(planText, card.title);
|
|
5403
|
+
let planId = null;
|
|
5404
|
+
try {
|
|
5405
|
+
const result = await this.client.createPlan(this.projectId, {
|
|
5406
|
+
title: artifact.title,
|
|
5407
|
+
content: artifact.markdown,
|
|
5408
|
+
source: "agent",
|
|
5409
|
+
tasks: artifact.tasks
|
|
5410
|
+
});
|
|
5411
|
+
const plan = result?.plan;
|
|
5412
|
+
const createdId = plan && typeof plan === "object" && "id" in plan ? plan.id : null;
|
|
5413
|
+
if (createdId) {
|
|
5414
|
+
await this.client.updateCard(card.id, {
|
|
5415
|
+
planId: createdId,
|
|
5416
|
+
needsPlanRefresh: false
|
|
5417
|
+
});
|
|
5418
|
+
planId = createdId;
|
|
5419
|
+
}
|
|
5420
|
+
log.info(this.tag, `Stored plan ${planId ?? "(unlinked)"} for #${card.short_id} (${artifact.tasks.length} tasks)`);
|
|
5421
|
+
} catch (err) {
|
|
5422
|
+
log.warn(this.tag, `Failed to store/link plan (non-fatal): ${err instanceof Error ? err.message : err}`);
|
|
5423
|
+
}
|
|
5424
|
+
if (planning.mode === "gated" && planId) {
|
|
5425
|
+
try {
|
|
5426
|
+
if (planning.postComment) {
|
|
5427
|
+
await this.client.addComment(card.id, buildGatedPlanComment(artifact, planning.awaitingApprovalColumn), {
|
|
5428
|
+
commentType: "decision",
|
|
5429
|
+
agentSessionId: this.sessionId ?? undefined
|
|
5430
|
+
});
|
|
5431
|
+
}
|
|
5432
|
+
await runTransition(this.client, card, {
|
|
5433
|
+
move: { columnName: planning.awaitingApprovalColumn },
|
|
5434
|
+
removeLabels: ["agent"],
|
|
5435
|
+
endSession: {
|
|
5436
|
+
status: "completed",
|
|
5437
|
+
progressPercent: 20,
|
|
5438
|
+
...buildTokenPayload(stats)
|
|
5439
|
+
}
|
|
5440
|
+
}, { store: this.stateStore, runId: this.runId ?? undefined });
|
|
5441
|
+
log.info(this.tag, `#${card.short_id} parked in "${planning.awaitingApprovalColumn}" for plan approval`);
|
|
5442
|
+
this.lastSessionStats = undefined;
|
|
5443
|
+
return true;
|
|
5444
|
+
} catch (err) {
|
|
5445
|
+
log.warn(this.tag, `Gated park failed for #${card.short_id} (non-fatal, implementing directly): ${err instanceof TransitionError ? err.detail : err instanceof Error ? err.message : err}`);
|
|
5446
|
+
}
|
|
5447
|
+
}
|
|
5448
|
+
if (planId && planning.postComment) {
|
|
5449
|
+
try {
|
|
5450
|
+
await this.client.addComment(card.id, buildPlanComment(artifact), {
|
|
5451
|
+
commentType: "decision",
|
|
5452
|
+
agentSessionId: this.sessionId ?? undefined
|
|
5453
|
+
});
|
|
5454
|
+
} catch (err) {
|
|
5455
|
+
log.warn(this.tag, `Failed to post advisory plan comment (non-fatal): ${err instanceof Error ? err.message : err}`);
|
|
5456
|
+
}
|
|
5457
|
+
}
|
|
5458
|
+
return false;
|
|
5459
|
+
}
|
|
5460
|
+
async spawnClaude(prompt, card, subtasks, opts = {}) {
|
|
5461
|
+
const model = opts.model ?? this.config.claude.model;
|
|
5462
|
+
const maxTurns = opts.maxTurns ?? this.config.claude.maxTurns;
|
|
5463
|
+
const allowedTools = opts.allowedTools ?? IMPLEMENT_ALLOWED_TOOLS;
|
|
5464
|
+
const initialPhase = opts.initialPhase ?? "exploring";
|
|
5021
5465
|
return new Promise((resolve3, reject) => {
|
|
5022
5466
|
const args = [
|
|
5023
5467
|
"-p",
|
|
@@ -5025,11 +5469,11 @@ class Worker {
|
|
|
5025
5469
|
"--output-format",
|
|
5026
5470
|
"stream-json",
|
|
5027
5471
|
"--model",
|
|
5028
|
-
|
|
5472
|
+
model,
|
|
5029
5473
|
"--max-turns",
|
|
5030
|
-
String(
|
|
5474
|
+
String(maxTurns),
|
|
5031
5475
|
"--allowedTools",
|
|
5032
|
-
|
|
5476
|
+
allowedTools,
|
|
5033
5477
|
...this.config.claude.additionalArgs,
|
|
5034
5478
|
"--",
|
|
5035
5479
|
prompt
|
|
@@ -5048,7 +5492,7 @@ class Worker {
|
|
|
5048
5492
|
stdio: ["ignore", "pipe", "pipe"]
|
|
5049
5493
|
});
|
|
5050
5494
|
const parser = new StreamParser;
|
|
5051
|
-
this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
|
|
5495
|
+
this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks, initialPhase);
|
|
5052
5496
|
if (this.sessionId) {
|
|
5053
5497
|
this.progressTracker.setSessionId(this.sessionId);
|
|
5054
5498
|
}
|
|
@@ -5093,7 +5537,9 @@ class Worker {
|
|
|
5093
5537
|
} else if (code === 0) {
|
|
5094
5538
|
resolve3();
|
|
5095
5539
|
} else {
|
|
5096
|
-
|
|
5540
|
+
const err = new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
|
|
5541
|
+
err.stderr = stderr;
|
|
5542
|
+
reject(err);
|
|
5097
5543
|
}
|
|
5098
5544
|
});
|
|
5099
5545
|
});
|
|
@@ -5125,11 +5571,13 @@ class Worker {
|
|
|
5125
5571
|
this.sessionId = null;
|
|
5126
5572
|
}
|
|
5127
5573
|
}
|
|
5128
|
-
var TAG23 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4;
|
|
5574
|
+
var TAG23 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4, PLAN_ALLOWED_TOOLS = "Read,Grep,Glob,mcp__harmony__*", IMPLEMENT_ALLOWED_TOOLS = "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*", PLAN_PHASE_TIMEOUT;
|
|
5129
5575
|
var init_worker = __esm(() => {
|
|
5130
5576
|
init_board_helpers();
|
|
5131
5577
|
init_completion();
|
|
5578
|
+
init_error_classifier();
|
|
5132
5579
|
init_log();
|
|
5580
|
+
init_plan_phase();
|
|
5133
5581
|
init_process_group();
|
|
5134
5582
|
init_progress_tracker();
|
|
5135
5583
|
init_prompt();
|
|
@@ -5139,6 +5587,7 @@ var init_worker = __esm(() => {
|
|
|
5139
5587
|
init_transitions();
|
|
5140
5588
|
init_types();
|
|
5141
5589
|
init_worktree();
|
|
5590
|
+
PLAN_PHASE_TIMEOUT = 10 * 60000;
|
|
5142
5591
|
});
|
|
5143
5592
|
|
|
5144
5593
|
// src/pool.ts
|
|
@@ -5154,6 +5603,8 @@ class Pool {
|
|
|
5154
5603
|
budget;
|
|
5155
5604
|
sleepGuard = new SleepGuard;
|
|
5156
5605
|
shuttingDown = false;
|
|
5606
|
+
apiCooldownUntil = 0;
|
|
5607
|
+
authPaused = false;
|
|
5157
5608
|
onCardCompleted = null;
|
|
5158
5609
|
constructor(config, client, _userEmail, workspaceId, projectId, stateStore, agentId) {
|
|
5159
5610
|
this.client = client;
|
|
@@ -5172,7 +5623,7 @@ class Pool {
|
|
|
5172
5623
|
}
|
|
5173
5624
|
}, workspaceId, projectId, stateStore, async (completedCard) => {
|
|
5174
5625
|
await this.onCardCompleted?.(completedCard);
|
|
5175
|
-
}));
|
|
5626
|
+
}, (err) => this.noteApiError(err)));
|
|
5176
5627
|
}
|
|
5177
5628
|
if (config.review.enabled) {
|
|
5178
5629
|
for (let i = 0;i < config.review.poolSize; i++) {
|
|
@@ -5193,6 +5644,17 @@ class Pool {
|
|
|
5193
5644
|
return;
|
|
5194
5645
|
}
|
|
5195
5646
|
if (mode === "implement") {
|
|
5647
|
+
if (this.authPaused) {
|
|
5648
|
+
log.debug(TAG24, `#${card.short_id} held — agent paused (auth error)`);
|
|
5649
|
+
await this.emitWaiting(card.id, "Agent paused — Anthropic auth error, check API credentials");
|
|
5650
|
+
return;
|
|
5651
|
+
}
|
|
5652
|
+
const cooldownMs = this.apiCooldownRemainingMs();
|
|
5653
|
+
if (cooldownMs > 0) {
|
|
5654
|
+
log.debug(TAG24, `#${card.short_id} held — API cooldown ${Math.round(cooldownMs / 1000)}s remaining`);
|
|
5655
|
+
await this.emitWaiting(card.id, `Paused — Anthropic API limit, retrying in ~${Math.round(cooldownMs / 1000)}s`);
|
|
5656
|
+
return;
|
|
5657
|
+
}
|
|
5196
5658
|
const decision = this.budget.check(card.id);
|
|
5197
5659
|
if (!decision.allow) {
|
|
5198
5660
|
if (decision.reason === "daily_budget") {
|
|
@@ -5242,6 +5704,26 @@ class Pool {
|
|
|
5242
5704
|
log.debug(TAG24, `waiting emit failed for ${cardId}: ${err instanceof Error ? err.message : err}`);
|
|
5243
5705
|
}
|
|
5244
5706
|
}
|
|
5707
|
+
noteApiError(err) {
|
|
5708
|
+
if (!err.kind)
|
|
5709
|
+
return;
|
|
5710
|
+
if (err.kind === "auth") {
|
|
5711
|
+
if (!this.authPaused) {
|
|
5712
|
+
log.error(TAG24, "Auth error from Claude CLI — pausing implement pickups until the daemon is restarted with valid credentials");
|
|
5713
|
+
}
|
|
5714
|
+
this.authPaused = true;
|
|
5715
|
+
return;
|
|
5716
|
+
}
|
|
5717
|
+
const cooldownMs = err.retryAfterMs ?? cooldownMsFor(err.kind);
|
|
5718
|
+
const until = Date.now() + cooldownMs;
|
|
5719
|
+
if (until > this.apiCooldownUntil) {
|
|
5720
|
+
this.apiCooldownUntil = until;
|
|
5721
|
+
log.warn(TAG24, `${describeApiError(err.kind)} — pausing implement pickups for ${Math.round(cooldownMs / 1000)}s`);
|
|
5722
|
+
}
|
|
5723
|
+
}
|
|
5724
|
+
apiCooldownRemainingMs() {
|
|
5725
|
+
return Math.max(0, this.apiCooldownUntil - Date.now());
|
|
5726
|
+
}
|
|
5245
5727
|
async removeCard(cardId) {
|
|
5246
5728
|
await this.stateStore.resetAttempts(cardId);
|
|
5247
5729
|
this.lastWaitingEmit.delete(cardId);
|
|
@@ -5369,6 +5851,7 @@ class Pool {
|
|
|
5369
5851
|
}
|
|
5370
5852
|
var TAG24 = "pool";
|
|
5371
5853
|
var init_pool = __esm(() => {
|
|
5854
|
+
init_error_classifier();
|
|
5372
5855
|
init_log();
|
|
5373
5856
|
init_queue();
|
|
5374
5857
|
init_review_worker();
|
|
@@ -5378,6 +5861,78 @@ var init_pool = __esm(() => {
|
|
|
5378
5861
|
init_worker();
|
|
5379
5862
|
});
|
|
5380
5863
|
|
|
5864
|
+
// src/port-registry.ts
|
|
5865
|
+
var exports_port_registry = {};
|
|
5866
|
+
__export(exports_port_registry, {
|
|
5867
|
+
recordDaemonPort: () => recordDaemonPort,
|
|
5868
|
+
lookupDaemonPort: () => lookupDaemonPort,
|
|
5869
|
+
defaultRegistryPath: () => defaultRegistryPath,
|
|
5870
|
+
clearDaemonPort: () => clearDaemonPort
|
|
5871
|
+
});
|
|
5872
|
+
import {
|
|
5873
|
+
existsSync as existsSync5,
|
|
5874
|
+
mkdirSync as mkdirSync3,
|
|
5875
|
+
readFileSync as readFileSync4,
|
|
5876
|
+
renameSync as renameSync2,
|
|
5877
|
+
writeFileSync as writeFileSync2
|
|
5878
|
+
} from "node:fs";
|
|
5879
|
+
import { homedir as homedir4 } from "node:os";
|
|
5880
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
5881
|
+
function defaultRegistryPath() {
|
|
5882
|
+
return join4(homedir4(), ".harmony-mcp", "agent-ports.json");
|
|
5883
|
+
}
|
|
5884
|
+
function load(path) {
|
|
5885
|
+
if (!existsSync5(path))
|
|
5886
|
+
return {};
|
|
5887
|
+
try {
|
|
5888
|
+
const raw = readFileSync4(path, "utf-8");
|
|
5889
|
+
const parsed = JSON.parse(raw);
|
|
5890
|
+
if (parsed && typeof parsed === "object")
|
|
5891
|
+
return parsed;
|
|
5892
|
+
return {};
|
|
5893
|
+
} catch (err) {
|
|
5894
|
+
log.warn(TAG25, `failed to read ${path}: ${err instanceof Error ? err.message : err}`);
|
|
5895
|
+
return {};
|
|
5896
|
+
}
|
|
5897
|
+
}
|
|
5898
|
+
function save(path, registry) {
|
|
5899
|
+
const dir = dirname2(path);
|
|
5900
|
+
if (!existsSync5(dir))
|
|
5901
|
+
mkdirSync3(dir, { recursive: true });
|
|
5902
|
+
const tmp = `${path}.tmp`;
|
|
5903
|
+
writeFileSync2(tmp, JSON.stringify(registry, null, 2), "utf-8");
|
|
5904
|
+
renameSync2(tmp, path);
|
|
5905
|
+
}
|
|
5906
|
+
function recordDaemonPort(projectId, entry, path = defaultRegistryPath()) {
|
|
5907
|
+
try {
|
|
5908
|
+
const registry = load(path);
|
|
5909
|
+
registry[projectId] = { ...entry, updatedAt: Date.now() };
|
|
5910
|
+
save(path, registry);
|
|
5911
|
+
} catch (err) {
|
|
5912
|
+
log.warn(TAG25, `failed to record port for ${projectId}: ${err instanceof Error ? err.message : err}`);
|
|
5913
|
+
}
|
|
5914
|
+
}
|
|
5915
|
+
function lookupDaemonPort(projectId, path = defaultRegistryPath()) {
|
|
5916
|
+
const registry = load(path);
|
|
5917
|
+
return registry[projectId] ?? null;
|
|
5918
|
+
}
|
|
5919
|
+
function clearDaemonPort(projectId, pid, path = defaultRegistryPath()) {
|
|
5920
|
+
try {
|
|
5921
|
+
const registry = load(path);
|
|
5922
|
+
const existing = registry[projectId];
|
|
5923
|
+
if (!existing || existing.pid !== pid)
|
|
5924
|
+
return;
|
|
5925
|
+
delete registry[projectId];
|
|
5926
|
+
save(path, registry);
|
|
5927
|
+
} catch (err) {
|
|
5928
|
+
log.warn(TAG25, `failed to clear port for ${projectId}: ${err instanceof Error ? err.message : err}`);
|
|
5929
|
+
}
|
|
5930
|
+
}
|
|
5931
|
+
var TAG25 = "port-registry";
|
|
5932
|
+
var init_port_registry = __esm(() => {
|
|
5933
|
+
init_log();
|
|
5934
|
+
});
|
|
5935
|
+
|
|
5381
5936
|
// src/recovery.ts
|
|
5382
5937
|
function isProcessAlive(pid, currentPid) {
|
|
5383
5938
|
if (pid === currentPid)
|
|
@@ -5394,7 +5949,7 @@ async function fetchCardSafely(client, cardId) {
|
|
|
5394
5949
|
const { card } = await client.getCard(cardId);
|
|
5395
5950
|
return card;
|
|
5396
5951
|
} catch (err) {
|
|
5397
|
-
log.warn(
|
|
5952
|
+
log.warn(TAG26, `cannot fetch card ${cardId}: ${err instanceof Error ? err.message : err}`);
|
|
5398
5953
|
return null;
|
|
5399
5954
|
}
|
|
5400
5955
|
}
|
|
@@ -5404,7 +5959,7 @@ async function recoverOrphans(store, client, config) {
|
|
|
5404
5959
|
return [];
|
|
5405
5960
|
}
|
|
5406
5961
|
const outcomes = [];
|
|
5407
|
-
log.info(
|
|
5962
|
+
log.info(TAG26, `recovering ${active.length} orphan run(s) from prior daemon`);
|
|
5408
5963
|
for (const run of active) {
|
|
5409
5964
|
const outcome = {
|
|
5410
5965
|
runId: run.runId,
|
|
@@ -5416,11 +5971,11 @@ async function recoverOrphans(store, client, config) {
|
|
|
5416
5971
|
};
|
|
5417
5972
|
outcomes.push(outcome);
|
|
5418
5973
|
if (isProcessAlive(run.daemonPid, process.pid)) {
|
|
5419
|
-
log.warn(
|
|
5974
|
+
log.warn(TAG26, `run ${run.runId} claims live daemon pid ${run.daemonPid} — skipping`);
|
|
5420
5975
|
outcome.actions.push("skipped: daemon pid still alive");
|
|
5421
5976
|
continue;
|
|
5422
5977
|
}
|
|
5423
|
-
log.info(
|
|
5978
|
+
log.info(TAG26, `recovering ${run.pipeline} run ${run.runId} for card #${run.cardShortId}`);
|
|
5424
5979
|
await recoverRun(run, store, client, config, outcome);
|
|
5425
5980
|
}
|
|
5426
5981
|
return outcomes;
|
|
@@ -5438,7 +5993,7 @@ async function recoverRun(run, store, client, config, outcome) {
|
|
|
5438
5993
|
} catch (err) {
|
|
5439
5994
|
const msg = err instanceof Error ? err.message : String(err);
|
|
5440
5995
|
outcome.errors.push(`endAgentSession: ${msg}`);
|
|
5441
|
-
log.warn(
|
|
5996
|
+
log.warn(TAG26, `endAgentSession failed for ${run.cardId}: ${msg}`);
|
|
5442
5997
|
}
|
|
5443
5998
|
const card = await fetchCardSafely(client, run.cardId);
|
|
5444
5999
|
if (card) {
|
|
@@ -5479,9 +6034,9 @@ async function recoverRun(run, store, client, config, outcome) {
|
|
|
5479
6034
|
const msg = err instanceof Error ? err.message : String(err);
|
|
5480
6035
|
outcome.errors.push(`endRun: ${msg}`);
|
|
5481
6036
|
}
|
|
5482
|
-
log.info(
|
|
6037
|
+
log.info(TAG26, `recovered run ${run.runId} (card #${run.cardShortId}): ${outcome.actions.join(", ")}${outcome.errors.length ? ` | errors: ${outcome.errors.join("; ")}` : ""}`);
|
|
5483
6038
|
}
|
|
5484
|
-
var
|
|
6039
|
+
var TAG26 = "recovery", RECOVERED_LABEL = "agent-recovered", RECOVERED_LABEL_COLOR = "#f59e0b";
|
|
5485
6040
|
var init_recovery = __esm(() => {
|
|
5486
6041
|
init_board_helpers();
|
|
5487
6042
|
init_log();
|
|
@@ -5529,7 +6084,7 @@ class Reconciler {
|
|
|
5529
6084
|
clearInterval(this.timer);
|
|
5530
6085
|
this.timer = null;
|
|
5531
6086
|
}
|
|
5532
|
-
log.info(
|
|
6087
|
+
log.info(TAG27, "Heartbeat stopped");
|
|
5533
6088
|
}
|
|
5534
6089
|
async recoverStaleRuns() {
|
|
5535
6090
|
if (!this.stateStore || !this.agentConfig)
|
|
@@ -5546,7 +6101,7 @@ class Reconciler {
|
|
|
5546
6101
|
if (!daemonDead && !(heartbeatStale && ourZombie))
|
|
5547
6102
|
continue;
|
|
5548
6103
|
const reason = daemonDead ? `foreign daemon ${run.daemonPid} is dead` : `our worker lost card ${run.cardId} with ${Math.round((now - run.lastHeartbeatAt) / 1000)}s stale heartbeat`;
|
|
5549
|
-
log.warn(
|
|
6104
|
+
log.warn(TAG27, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
|
|
5550
6105
|
await recoverRun(run, this.stateStore, this.client, this.agentConfig, {
|
|
5551
6106
|
runId: run.runId,
|
|
5552
6107
|
cardId: run.cardId,
|
|
@@ -5557,6 +6112,57 @@ class Reconciler {
|
|
|
5557
6112
|
});
|
|
5558
6113
|
}
|
|
5559
6114
|
}
|
|
6115
|
+
async recoverStrandedInProgress(cards, columns, knownCardIds) {
|
|
6116
|
+
const inProgressCol = columns.find((c) => c.name.toLowerCase() === IN_PROGRESS_COLUMN.toLowerCase());
|
|
6117
|
+
const pickupName = this.pickupColumns[0];
|
|
6118
|
+
const pickupCol = pickupName ? columns.find((c) => c.name.toLowerCase() === pickupName.toLowerCase()) : undefined;
|
|
6119
|
+
if (!inProgressCol || !pickupCol)
|
|
6120
|
+
return;
|
|
6121
|
+
const graceMs = this.agentConfig?.timing.staleHeartbeatMs ?? 120000;
|
|
6122
|
+
const now = Date.now();
|
|
6123
|
+
const activeCardIds = new Set((this.stateStore?.getActiveRuns() ?? []).map((r) => r.cardId));
|
|
6124
|
+
for (const card of cards) {
|
|
6125
|
+
if (card.assigned_agent_id !== this.agentId || card.archived_at || card.column_id !== inProgressCol.id || knownCardIds.has(card.id) || activeCardIds.has(card.id)) {
|
|
6126
|
+
continue;
|
|
6127
|
+
}
|
|
6128
|
+
const stalledAt = Date.parse(card.updated_at ?? "");
|
|
6129
|
+
if (!Number.isFinite(stalledAt) || now - stalledAt < graceMs)
|
|
6130
|
+
continue;
|
|
6131
|
+
log.warn(TAG27, `#${card.short_id} stranded in "${inProgressCol.name}" (no live run) — requeueing to "${pickupCol.name}"`);
|
|
6132
|
+
try {
|
|
6133
|
+
await this.client.moveCard(card.id, pickupCol.id);
|
|
6134
|
+
} catch (err) {
|
|
6135
|
+
log.error(TAG27, `stranded requeue failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
|
|
6136
|
+
}
|
|
6137
|
+
}
|
|
6138
|
+
}
|
|
6139
|
+
async releaseStalledApprovals(cards, columns, knownCardIds) {
|
|
6140
|
+
const planning = this.agentConfig?.planning;
|
|
6141
|
+
if (!planning?.enabled || planning.mode !== "gated" || planning.approvalTtlHours <= 0) {
|
|
6142
|
+
return;
|
|
6143
|
+
}
|
|
6144
|
+
const parkCol = columns.find((c) => c.name.toLowerCase() === planning.awaitingApprovalColumn.toLowerCase());
|
|
6145
|
+
const pickupName = this.pickupColumns[0];
|
|
6146
|
+
const pickupCol = pickupName ? columns.find((c) => c.name.toLowerCase() === pickupName.toLowerCase()) : undefined;
|
|
6147
|
+
if (!parkCol || !pickupCol)
|
|
6148
|
+
return;
|
|
6149
|
+
const ttlMs = planning.approvalTtlHours * 3600000;
|
|
6150
|
+
const now = Date.now();
|
|
6151
|
+
for (const card of cards) {
|
|
6152
|
+
if (card.assigned_agent_id !== this.agentId || card.archived_at || card.column_id !== parkCol.id || !card.plan_id || knownCardIds.has(card.id)) {
|
|
6153
|
+
continue;
|
|
6154
|
+
}
|
|
6155
|
+
const parkedAt = Date.parse(card.updated_at ?? "");
|
|
6156
|
+
if (!Number.isFinite(parkedAt) || now - parkedAt < ttlMs)
|
|
6157
|
+
continue;
|
|
6158
|
+
log.warn(TAG27, `#${card.short_id} parked for approval > ${planning.approvalTtlHours}h — auto-releasing to "${pickupCol.name}"`);
|
|
6159
|
+
try {
|
|
6160
|
+
await this.client.moveCard(card.id, pickupCol.id);
|
|
6161
|
+
} catch (err) {
|
|
6162
|
+
log.error(TAG27, `auto-release failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
|
|
6163
|
+
}
|
|
6164
|
+
}
|
|
6165
|
+
}
|
|
5560
6166
|
async tick() {
|
|
5561
6167
|
this.lastTickAt = Date.now();
|
|
5562
6168
|
try {
|
|
@@ -5582,37 +6188,39 @@ class Reconciler {
|
|
|
5582
6188
|
const subtasks = card.subtasks ?? [];
|
|
5583
6189
|
const mode = reviewColumnIds.has(card.column_id) ? "review" : "implement";
|
|
5584
6190
|
if (mode === "review" && this.approvedLabel && hasLabel(cardLabels, this.approvedLabel)) {
|
|
5585
|
-
log.debug(
|
|
6191
|
+
log.debug(TAG27, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
|
|
5586
6192
|
continue;
|
|
5587
6193
|
}
|
|
5588
6194
|
if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
|
|
5589
|
-
log.debug(
|
|
6195
|
+
log.debug(TAG27, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
|
|
5590
6196
|
continue;
|
|
5591
6197
|
}
|
|
5592
6198
|
if (mode === "review" && !extractBranchFromDescription(card.description)) {
|
|
5593
|
-
log.debug(
|
|
6199
|
+
log.debug(TAG27, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
|
|
5594
6200
|
continue;
|
|
5595
6201
|
}
|
|
5596
|
-
log.info(
|
|
6202
|
+
log.info(TAG27, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
|
|
5597
6203
|
await this.pool.enqueue(card, column, cardLabels, subtasks, mode);
|
|
5598
6204
|
}
|
|
5599
6205
|
}
|
|
5600
6206
|
if (this.stateStore && this.agentConfig) {
|
|
5601
6207
|
await this.recoverStaleRuns();
|
|
5602
6208
|
}
|
|
6209
|
+
await this.recoverStrandedInProgress(cards, columns, knownCardIds);
|
|
5603
6210
|
for (const knownId of knownCardIds) {
|
|
5604
6211
|
if (!allAgentCardIds.has(knownId)) {
|
|
5605
|
-
log.info(
|
|
6212
|
+
log.info(TAG27, `Missed unassign: ${knownId} — removing`);
|
|
5606
6213
|
await this.pool.removeCard(knownId);
|
|
5607
6214
|
}
|
|
5608
6215
|
}
|
|
5609
|
-
|
|
6216
|
+
await this.releaseStalledApprovals(cards, columns, knownCardIds);
|
|
6217
|
+
log.debug(TAG27, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
|
|
5610
6218
|
} catch (err) {
|
|
5611
|
-
log.error(
|
|
6219
|
+
log.error(TAG27, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
|
|
5612
6220
|
}
|
|
5613
6221
|
}
|
|
5614
6222
|
}
|
|
5615
|
-
var
|
|
6223
|
+
var TAG27 = "reconcile";
|
|
5616
6224
|
var init_reconcile = __esm(() => {
|
|
5617
6225
|
init_board_helpers();
|
|
5618
6226
|
init_log();
|
|
@@ -5633,6 +6241,7 @@ function prettyBanner(config, version) {
|
|
|
5633
6241
|
const checks = [];
|
|
5634
6242
|
let projectName;
|
|
5635
6243
|
let gitProvider;
|
|
6244
|
+
let httpPort;
|
|
5636
6245
|
let failed = false;
|
|
5637
6246
|
let rendered = false;
|
|
5638
6247
|
return {
|
|
@@ -5642,11 +6251,14 @@ function prettyBanner(config, version) {
|
|
|
5642
6251
|
setGitProvider(provider) {
|
|
5643
6252
|
gitProvider = provider;
|
|
5644
6253
|
},
|
|
6254
|
+
setHttpPort(port) {
|
|
6255
|
+
httpPort = port;
|
|
6256
|
+
},
|
|
5645
6257
|
check(message) {
|
|
5646
6258
|
checks.push({ kind: "ok", message });
|
|
5647
6259
|
},
|
|
5648
6260
|
warn(message) {
|
|
5649
|
-
log.warn(
|
|
6261
|
+
log.warn(TAG28, message);
|
|
5650
6262
|
checks.push({ kind: "warn", message: message.split(`
|
|
5651
6263
|
`, 1)[0] });
|
|
5652
6264
|
},
|
|
@@ -5662,6 +6274,7 @@ function prettyBanner(config, version) {
|
|
|
5662
6274
|
config,
|
|
5663
6275
|
projectName,
|
|
5664
6276
|
gitProvider,
|
|
6277
|
+
httpPort,
|
|
5665
6278
|
checks,
|
|
5666
6279
|
readyMessage: message
|
|
5667
6280
|
});
|
|
@@ -5670,22 +6283,25 @@ function prettyBanner(config, version) {
|
|
|
5670
6283
|
};
|
|
5671
6284
|
}
|
|
5672
6285
|
function jsonBanner(config, version) {
|
|
5673
|
-
log.info(
|
|
5674
|
-
log.info(
|
|
6286
|
+
log.info(TAG28, `Harmony Agent Daemon v${version} starting...`);
|
|
6287
|
+
log.info(TAG28, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
|
|
5675
6288
|
if (config.agent.review.enabled) {
|
|
5676
|
-
log.info(
|
|
6289
|
+
log.info(TAG28, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
|
|
5677
6290
|
}
|
|
5678
6291
|
let failed = false;
|
|
5679
6292
|
return {
|
|
5680
6293
|
setProjectName(_name) {},
|
|
5681
6294
|
setGitProvider(provider) {
|
|
5682
|
-
log.info(
|
|
6295
|
+
log.info(TAG28, `Git provider: ${provider}`);
|
|
6296
|
+
},
|
|
6297
|
+
setHttpPort(port) {
|
|
6298
|
+
log.info(TAG28, `HTTP server on port ${port}`);
|
|
5683
6299
|
},
|
|
5684
6300
|
check(message) {
|
|
5685
|
-
log.info(
|
|
6301
|
+
log.info(TAG28, message);
|
|
5686
6302
|
},
|
|
5687
6303
|
warn(message) {
|
|
5688
|
-
log.warn(
|
|
6304
|
+
log.warn(TAG28, message);
|
|
5689
6305
|
},
|
|
5690
6306
|
fail() {
|
|
5691
6307
|
failed = true;
|
|
@@ -5693,17 +6309,25 @@ function jsonBanner(config, version) {
|
|
|
5693
6309
|
async ready(message) {
|
|
5694
6310
|
if (failed)
|
|
5695
6311
|
return;
|
|
5696
|
-
log.info(
|
|
6312
|
+
log.info(TAG28, message);
|
|
5697
6313
|
}
|
|
5698
6314
|
};
|
|
5699
6315
|
}
|
|
5700
6316
|
function renderPretty(input) {
|
|
5701
|
-
const {
|
|
6317
|
+
const {
|
|
6318
|
+
version,
|
|
6319
|
+
config,
|
|
6320
|
+
projectName,
|
|
6321
|
+
gitProvider,
|
|
6322
|
+
httpPort,
|
|
6323
|
+
checks,
|
|
6324
|
+
readyMessage
|
|
6325
|
+
} = input;
|
|
5702
6326
|
const lines = [];
|
|
5703
6327
|
lines.push("");
|
|
5704
6328
|
lines.push(titleRule(`Harmony Agent Daemon v${version}`));
|
|
5705
6329
|
lines.push("");
|
|
5706
|
-
for (const row of configRows(config, projectName, gitProvider)) {
|
|
6330
|
+
for (const row of configRows(config, projectName, gitProvider, httpPort)) {
|
|
5707
6331
|
lines.push(` ${dim(row.label.padEnd(9))} ${row.value}`);
|
|
5708
6332
|
}
|
|
5709
6333
|
lines.push("");
|
|
@@ -5717,7 +6341,7 @@ function renderPretty(input) {
|
|
|
5717
6341
|
return lines.join(`
|
|
5718
6342
|
`);
|
|
5719
6343
|
}
|
|
5720
|
-
function configRows(config, projectName, gitProvider) {
|
|
6344
|
+
function configRows(config, projectName, gitProvider, httpPort) {
|
|
5721
6345
|
const rows = [];
|
|
5722
6346
|
const projectLabel = projectName ? `${projectName} (${shortenId(config.projectId)})` : shortenId(config.projectId);
|
|
5723
6347
|
rows.push({ label: "Project", value: projectLabel });
|
|
@@ -5733,7 +6357,7 @@ function configRows(config, projectName, gitProvider) {
|
|
|
5733
6357
|
if (gitProvider)
|
|
5734
6358
|
tail.push(gitProvider);
|
|
5735
6359
|
if (config.agent.http.enabled) {
|
|
5736
|
-
tail.push(`HTTP http://${config.agent.http.bindAddr}:${config.agent.http.port}`);
|
|
6360
|
+
tail.push(`HTTP http://${config.agent.http.bindAddr}:${httpPort ?? config.agent.http.port}`);
|
|
5737
6361
|
}
|
|
5738
6362
|
if (tail.length > 0) {
|
|
5739
6363
|
rows.push({ label: "Git", value: tail.join(" · ") });
|
|
@@ -5760,7 +6384,7 @@ function cyan(s) {
|
|
|
5760
6384
|
function yellow(s) {
|
|
5761
6385
|
return `${ANSI.yellow}${s}${ANSI.reset}`;
|
|
5762
6386
|
}
|
|
5763
|
-
var
|
|
6387
|
+
var TAG28 = "daemon", RULE_WIDTH = 70, ANSI;
|
|
5764
6388
|
var init_startup_banner = __esm(() => {
|
|
5765
6389
|
init_log();
|
|
5766
6390
|
ANSI = {
|
|
@@ -5907,18 +6531,18 @@ class Watcher {
|
|
|
5907
6531
|
}
|
|
5908
6532
|
async start() {
|
|
5909
6533
|
if (!isPretty()) {
|
|
5910
|
-
log.info(
|
|
6534
|
+
log.info(TAG29, "Connecting to Supabase realtime (broadcast)...");
|
|
5911
6535
|
}
|
|
5912
6536
|
this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
|
|
5913
6537
|
const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
|
|
5914
6538
|
const channel = this.supabase.channel(`board-${this.projectId}`).on("broadcast", { event: "card_update" }, (msg) => {
|
|
5915
|
-
log.debug(
|
|
6539
|
+
log.debug(TAG29, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
|
|
5916
6540
|
this.onCardBroadcast({
|
|
5917
6541
|
event: "card_update",
|
|
5918
6542
|
payload: msg.payload ?? {}
|
|
5919
6543
|
});
|
|
5920
6544
|
}).on("broadcast", { event: "card_created" }, (msg) => {
|
|
5921
|
-
log.debug(
|
|
6545
|
+
log.debug(TAG29, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
|
|
5922
6546
|
this.onCardBroadcast({
|
|
5923
6547
|
event: "card_created",
|
|
5924
6548
|
payload: msg.payload ?? {}
|
|
@@ -5928,29 +6552,29 @@ class Watcher {
|
|
|
5928
6552
|
const cardId = payload.card_id;
|
|
5929
6553
|
const command = payload.command;
|
|
5930
6554
|
if (cardId && command) {
|
|
5931
|
-
log.info(
|
|
6555
|
+
log.info(TAG29, `Broadcast: agent_command ${command} for ${cardId}`);
|
|
5932
6556
|
this.onAgentCommand?.({ cardId, command });
|
|
5933
6557
|
}
|
|
5934
6558
|
}).subscribe((status) => {
|
|
5935
6559
|
if (status === "SUBSCRIBED") {
|
|
5936
6560
|
this.connected = true;
|
|
5937
6561
|
if (!isPretty() || !this.suppressStartupLogs) {
|
|
5938
|
-
log.info(
|
|
6562
|
+
log.info(TAG29, "Broadcast subscription active");
|
|
5939
6563
|
}
|
|
5940
6564
|
this.maybeResolveReady();
|
|
5941
6565
|
} else if (status === "CHANNEL_ERROR") {
|
|
5942
6566
|
this.connected = false;
|
|
5943
|
-
log.error(
|
|
6567
|
+
log.error(TAG29, "Broadcast channel error — will rely on reconciliation");
|
|
5944
6568
|
} else if (status === "TIMED_OUT") {
|
|
5945
6569
|
this.connected = false;
|
|
5946
|
-
log.warn(
|
|
6570
|
+
log.warn(TAG29, "Broadcast subscription timed out — retrying...");
|
|
5947
6571
|
} else if (status === "CLOSED") {
|
|
5948
6572
|
this.connected = false;
|
|
5949
6573
|
}
|
|
5950
6574
|
});
|
|
5951
6575
|
this.channel = channel;
|
|
5952
6576
|
presenceChannel.on("presence", { event: "sync" }, () => {
|
|
5953
|
-
log.debug(
|
|
6577
|
+
log.debug(TAG29, "Presence sync");
|
|
5954
6578
|
}).subscribe(async (status) => {
|
|
5955
6579
|
if (status === "SUBSCRIBED") {
|
|
5956
6580
|
await presenceChannel.track({
|
|
@@ -5963,7 +6587,7 @@ class Watcher {
|
|
|
5963
6587
|
agentName: this.identity.agentName
|
|
5964
6588
|
});
|
|
5965
6589
|
if (!isPretty() || !this.suppressStartupLogs) {
|
|
5966
|
-
log.info(
|
|
6590
|
+
log.info(TAG29, "Presence tracked on board-presence channel");
|
|
5967
6591
|
}
|
|
5968
6592
|
this.presenceTracked = true;
|
|
5969
6593
|
this.maybeResolveReady();
|
|
@@ -5985,10 +6609,10 @@ class Watcher {
|
|
|
5985
6609
|
this.supabase = null;
|
|
5986
6610
|
}
|
|
5987
6611
|
this.connected = false;
|
|
5988
|
-
log.info(
|
|
6612
|
+
log.info(TAG29, "Broadcast subscription stopped");
|
|
5989
6613
|
}
|
|
5990
6614
|
}
|
|
5991
|
-
var
|
|
6615
|
+
var TAG29 = "watcher";
|
|
5992
6616
|
var init_watcher = __esm(() => {
|
|
5993
6617
|
init_log();
|
|
5994
6618
|
});
|
|
@@ -6063,10 +6687,10 @@ function runWorktreeGc(basePath, store, opts = {}) {
|
|
|
6063
6687
|
});
|
|
6064
6688
|
} catch {}
|
|
6065
6689
|
if (result.removed.length > 0) {
|
|
6066
|
-
log.info(
|
|
6690
|
+
log.info(TAG30, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
|
|
6067
6691
|
}
|
|
6068
6692
|
if (result.errors.length > 0) {
|
|
6069
|
-
log.warn(
|
|
6693
|
+
log.warn(TAG30, `GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
|
|
6070
6694
|
}
|
|
6071
6695
|
return result;
|
|
6072
6696
|
}
|
|
@@ -6143,10 +6767,10 @@ function pruneFailedRemoteBranches(opts) {
|
|
|
6143
6767
|
}
|
|
6144
6768
|
}
|
|
6145
6769
|
if (result.removed.length > 0) {
|
|
6146
|
-
log.info(
|
|
6770
|
+
log.info(TAG30, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
|
|
6147
6771
|
}
|
|
6148
6772
|
if (result.errors.length > 0) {
|
|
6149
|
-
log.warn(
|
|
6773
|
+
log.warn(TAG30, `Remote branch GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.ref}: ${e.error}`).join("; ")}`);
|
|
6150
6774
|
}
|
|
6151
6775
|
return result;
|
|
6152
6776
|
}
|
|
@@ -6177,13 +6801,13 @@ class WorktreeGc {
|
|
|
6177
6801
|
try {
|
|
6178
6802
|
runWorktreeGc(this.basePath, this.store);
|
|
6179
6803
|
} catch (err) {
|
|
6180
|
-
log.warn(
|
|
6804
|
+
log.warn(TAG30, `GC tick failed: ${err instanceof Error ? err.message : err}`);
|
|
6181
6805
|
}
|
|
6182
6806
|
if (this.remoteOpts) {
|
|
6183
6807
|
try {
|
|
6184
6808
|
pruneFailedRemoteBranches(this.remoteOpts);
|
|
6185
6809
|
} catch (err) {
|
|
6186
|
-
log.warn(
|
|
6810
|
+
log.warn(TAG30, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
|
|
6187
6811
|
}
|
|
6188
6812
|
}
|
|
6189
6813
|
}
|
|
@@ -6197,7 +6821,7 @@ function getRepoRoot2() {
|
|
|
6197
6821
|
return null;
|
|
6198
6822
|
}
|
|
6199
6823
|
}
|
|
6200
|
-
var
|
|
6824
|
+
var TAG30 = "worktree-gc";
|
|
6201
6825
|
var init_worktree_gc = __esm(() => {
|
|
6202
6826
|
init_log();
|
|
6203
6827
|
init_worktree();
|
|
@@ -6279,7 +6903,7 @@ async function main() {
|
|
|
6279
6903
|
} catch (err) {
|
|
6280
6904
|
if (err instanceof ConfigValidationError) {
|
|
6281
6905
|
banner.fail();
|
|
6282
|
-
log.error(
|
|
6906
|
+
log.error(TAG31, err.message);
|
|
6283
6907
|
process.exit(1);
|
|
6284
6908
|
}
|
|
6285
6909
|
throw err;
|
|
@@ -6388,25 +7012,28 @@ async function main() {
|
|
|
6388
7012
|
if (shuttingDown)
|
|
6389
7013
|
return;
|
|
6390
7014
|
shuttingDown = true;
|
|
6391
|
-
log.info(
|
|
7015
|
+
log.info(TAG31, `Received ${signal}, shutting down gracefully...`);
|
|
6392
7016
|
reconciler.stop();
|
|
6393
7017
|
mergeMonitor?.stop();
|
|
6394
7018
|
worktreeGc.stop();
|
|
6395
|
-
|
|
7019
|
+
if (httpServer) {
|
|
7020
|
+
clearDaemonPort(config.projectId, process.pid);
|
|
7021
|
+
await httpServer.stop();
|
|
7022
|
+
}
|
|
6396
7023
|
await watcher.stop();
|
|
6397
7024
|
await pool.shutdown();
|
|
6398
|
-
log.info(
|
|
7025
|
+
log.info(TAG31, "Daemon stopped.");
|
|
6399
7026
|
process.exit(exitCode);
|
|
6400
7027
|
};
|
|
6401
7028
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
6402
7029
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
6403
7030
|
process.on("uncaughtException", (err) => {
|
|
6404
|
-
log.error(
|
|
7031
|
+
log.error(TAG31, `Uncaught exception: ${err.message}`);
|
|
6405
7032
|
exitCode = 1;
|
|
6406
7033
|
shutdown("uncaughtException");
|
|
6407
7034
|
});
|
|
6408
7035
|
process.on("unhandledRejection", (reason) => {
|
|
6409
|
-
log.error(
|
|
7036
|
+
log.error(TAG31, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
6410
7037
|
exitCode = 1;
|
|
6411
7038
|
shutdown("unhandledRejection");
|
|
6412
7039
|
});
|
|
@@ -6416,7 +7043,13 @@ async function main() {
|
|
|
6416
7043
|
worktreeGc.start();
|
|
6417
7044
|
if (httpServer) {
|
|
6418
7045
|
try {
|
|
6419
|
-
await httpServer.start();
|
|
7046
|
+
const boundPort = await httpServer.start();
|
|
7047
|
+
recordDaemonPort(config.projectId, {
|
|
7048
|
+
port: boundPort,
|
|
7049
|
+
pid: process.pid,
|
|
7050
|
+
bindAddr: config.agent.http.bindAddr
|
|
7051
|
+
});
|
|
7052
|
+
banner.setHttpPort(boundPort);
|
|
6420
7053
|
} catch (err) {
|
|
6421
7054
|
banner.warn(`HTTP server failed to bind: ${err instanceof Error ? err.message : err}`);
|
|
6422
7055
|
}
|
|
@@ -6453,34 +7086,34 @@ async function handleBroadcast(event, client, pool, config, agentId) {
|
|
|
6453
7086
|
if (assignedAgentId === undefined)
|
|
6454
7087
|
return;
|
|
6455
7088
|
if (assignedAgentId === agentId) {
|
|
6456
|
-
log.info(
|
|
7089
|
+
log.info(TAG31, `Broadcast: card ${cardId} assigned to agent`);
|
|
6457
7090
|
try {
|
|
6458
7091
|
await tryEnqueueCard(cardId, client, pool, config, agentId);
|
|
6459
7092
|
} catch (err) {
|
|
6460
|
-
log.error(
|
|
7093
|
+
log.error(TAG31, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
|
|
6461
7094
|
}
|
|
6462
7095
|
} else if (pool.isCardKnown(cardId)) {
|
|
6463
|
-
log.info(
|
|
7096
|
+
log.info(TAG31, `Broadcast: card ${cardId} unassigned from agent`);
|
|
6464
7097
|
await pool.removeCard(cardId);
|
|
6465
7098
|
}
|
|
6466
7099
|
}
|
|
6467
7100
|
async function tryEnqueueCard(cardId, client, pool, config, agentId) {
|
|
6468
7101
|
const { card } = await client.getCard(cardId);
|
|
6469
7102
|
if (card.assigned_agent_id !== agentId) {
|
|
6470
|
-
log.debug(
|
|
7103
|
+
log.debug(TAG31, `Card ${cardId} no longer assigned to agent — skipping`);
|
|
6471
7104
|
return;
|
|
6472
7105
|
}
|
|
6473
7106
|
const board = await client.getBoard(config.projectId, { summary: true });
|
|
6474
7107
|
const columns = board.columns;
|
|
6475
7108
|
const column = columns.find((c) => c.id === card.column_id);
|
|
6476
7109
|
if (!column) {
|
|
6477
|
-
log.warn(
|
|
7110
|
+
log.warn(TAG31, `Column not found for card ${cardId}`);
|
|
6478
7111
|
return;
|
|
6479
7112
|
}
|
|
6480
7113
|
const isPickupColumn = config.agent.pickupColumns.some((name) => name.toLowerCase() === column.name.toLowerCase());
|
|
6481
7114
|
const isReviewColumn = config.agent.review.enabled && config.agent.review.pickupColumns.some((name) => name.toLowerCase() === column.name.toLowerCase());
|
|
6482
7115
|
if (!isPickupColumn && !isReviewColumn) {
|
|
6483
|
-
log.info(
|
|
7116
|
+
log.info(TAG31, `Card #${card.short_id} is in "${column.name}", not a pickup/review column — skipping`);
|
|
6484
7117
|
return;
|
|
6485
7118
|
}
|
|
6486
7119
|
const mode = isReviewColumn ? "review" : "implement";
|
|
@@ -6488,16 +7121,16 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
|
|
|
6488
7121
|
const cardLabels = resolveCardLabels(card, labelMap);
|
|
6489
7122
|
const subtasks = card.subtasks ?? [];
|
|
6490
7123
|
if (mode === "review" && config.agent.review.approvedLabel && hasLabel(cardLabels, config.agent.review.approvedLabel)) {
|
|
6491
|
-
log.debug(
|
|
7124
|
+
log.debug(TAG31, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
|
|
6492
7125
|
return;
|
|
6493
7126
|
}
|
|
6494
7127
|
if (mode === "review" && !extractBranchFromDescription(card.description)) {
|
|
6495
|
-
log.info(
|
|
7128
|
+
log.info(TAG31, `Card #${card.short_id} has no branch reference — skipping auto-review`);
|
|
6496
7129
|
return;
|
|
6497
7130
|
}
|
|
6498
7131
|
await pool.enqueue(card, column, cardLabels, subtasks, mode);
|
|
6499
7132
|
}
|
|
6500
|
-
var
|
|
7133
|
+
var TAG31 = "daemon", PKG_VERSION;
|
|
6501
7134
|
var init_src = __esm(() => {
|
|
6502
7135
|
init_board_helpers();
|
|
6503
7136
|
init_config();
|
|
@@ -6507,6 +7140,7 @@ var init_src = __esm(() => {
|
|
|
6507
7140
|
init_log();
|
|
6508
7141
|
init_merge_monitor();
|
|
6509
7142
|
init_pool();
|
|
7143
|
+
init_port_registry();
|
|
6510
7144
|
init_reconcile();
|
|
6511
7145
|
init_recovery();
|
|
6512
7146
|
init_review_worktree();
|
|
@@ -6546,8 +7180,12 @@ async function runDaemon() {
|
|
|
6546
7180
|
}
|
|
6547
7181
|
async function httpCall(path, init) {
|
|
6548
7182
|
const { loadDaemonConfig: loadDaemonConfig2 } = await Promise.resolve().then(() => (init_config(), exports_config));
|
|
7183
|
+
const { lookupDaemonPort: lookupDaemonPort2 } = await Promise.resolve().then(() => (init_port_registry(), exports_port_registry));
|
|
6549
7184
|
const cfg = loadDaemonConfig2();
|
|
6550
|
-
const
|
|
7185
|
+
const recorded = lookupDaemonPort2(cfg.projectId);
|
|
7186
|
+
const bindAddr = recorded?.bindAddr ?? cfg.agent.http.bindAddr;
|
|
7187
|
+
const port = recorded?.port ?? cfg.agent.http.port;
|
|
7188
|
+
const url = `http://${bindAddr}:${port}${path}`;
|
|
6551
7189
|
return fetch(url, init);
|
|
6552
7190
|
}
|
|
6553
7191
|
function printStatus(body) {
|