@gethmy/agent 1.7.1 → 1.7.3
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 +6386 -141
- package/dist/index.js +6216 -333
- package/package.json +2 -2
- package/dist/board-helpers.d.ts +0 -31
- package/dist/board-helpers.js +0 -150
- package/dist/budget.d.ts +0 -39
- package/dist/budget.js +0 -73
- package/dist/cli.d.ts +0 -14
- package/dist/completion.d.ts +0 -36
- package/dist/completion.js +0 -322
- package/dist/config-validation.d.ts +0 -23
- package/dist/config-validation.js +0 -77
- package/dist/config.d.ts +0 -23
- package/dist/config.js +0 -103
- package/dist/episode-writer.d.ts +0 -116
- package/dist/episode-writer.js +0 -349
- package/dist/git-diff-stat.d.ts +0 -24
- package/dist/git-diff-stat.js +0 -56
- package/dist/git-pr.d.ts +0 -38
- package/dist/git-pr.js +0 -399
- package/dist/http-server.d.ts +0 -66
- package/dist/http-server.js +0 -96
- package/dist/index.d.ts +0 -5
- package/dist/log.d.ts +0 -34
- package/dist/log.js +0 -100
- package/dist/merge-monitor.d.ts +0 -23
- package/dist/merge-monitor.js +0 -169
- package/dist/pm.d.ts +0 -14
- package/dist/pm.js +0 -63
- package/dist/pool.d.ts +0 -71
- package/dist/pool.js +0 -259
- package/dist/process-group.d.ts +0 -26
- package/dist/process-group.js +0 -72
- package/dist/progress-tracker.d.ts +0 -82
- package/dist/progress-tracker.js +0 -457
- package/dist/prompt.d.ts +0 -23
- package/dist/prompt.js +0 -160
- package/dist/queue.d.ts +0 -39
- package/dist/queue.js +0 -100
- package/dist/reconcile.d.ts +0 -35
- package/dist/reconcile.js +0 -174
- package/dist/recovery.d.ts +0 -30
- package/dist/recovery.js +0 -141
- package/dist/review-completion.d.ts +0 -35
- package/dist/review-completion.js +0 -475
- package/dist/review-knowledge.d.ts +0 -14
- package/dist/review-knowledge.js +0 -89
- package/dist/review-prompt.d.ts +0 -12
- package/dist/review-prompt.js +0 -103
- package/dist/review-worker.d.ts +0 -56
- package/dist/review-worker.js +0 -638
- package/dist/review-worktree.d.ts +0 -12
- package/dist/review-worktree.js +0 -95
- package/dist/run-log.d.ts +0 -6
- package/dist/run-log.js +0 -19
- package/dist/startup-banner.d.ts +0 -29
- package/dist/startup-banner.js +0 -143
- package/dist/state-store.d.ts +0 -89
- package/dist/state-store.js +0 -230
- package/dist/stream-parser-selftest.d.ts +0 -9
- package/dist/stream-parser-selftest.js +0 -97
- package/dist/stream-parser.d.ts +0 -43
- package/dist/stream-parser.js +0 -174
- package/dist/transitions.d.ts +0 -57
- package/dist/transitions.js +0 -131
- package/dist/types.d.ts +0 -167
- package/dist/types.js +0 -76
- package/dist/verification.d.ts +0 -39
- package/dist/verification.js +0 -317
- package/dist/watcher.d.ts +0 -53
- package/dist/watcher.js +0 -153
- package/dist/worker.d.ts +0 -54
- package/dist/worker.js +0 -507
- package/dist/worktree-gc.d.ts +0 -67
- package/dist/worktree-gc.js +0 -245
- package/dist/worktree.d.ts +0 -18
- package/dist/worktree.js +0 -177
package/dist/queue.js
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { log } from "./log.js";
|
|
2
|
-
const TAG = "queue";
|
|
3
|
-
/**
|
|
4
|
-
* Priority queue for cards waiting to be worked on.
|
|
5
|
-
* Sorted by: label priority boost > column position boost > enqueue time (FIFO).
|
|
6
|
-
*/
|
|
7
|
-
export class PriorityQueue {
|
|
8
|
-
config;
|
|
9
|
-
items = [];
|
|
10
|
-
constructor(config) {
|
|
11
|
-
this.config = config;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Calculate priority score for a card.
|
|
15
|
-
*/
|
|
16
|
-
scoreCard(_card, column, labels) {
|
|
17
|
-
let score = 0;
|
|
18
|
-
// Label boost: highest matching label wins
|
|
19
|
-
for (const label of labels) {
|
|
20
|
-
const boost = this.config.priorityLabels[label.name.toLowerCase()] ?? 0;
|
|
21
|
-
if (boost > score)
|
|
22
|
-
score = boost;
|
|
23
|
-
}
|
|
24
|
-
// Column position boost: leftmost columns get higher priority
|
|
25
|
-
if (this.config.columnBoost) {
|
|
26
|
-
score += Math.max(0, 100 - column.position * 10);
|
|
27
|
-
}
|
|
28
|
-
return score;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Add a card to the queue. If already present, update its priority.
|
|
32
|
-
*/
|
|
33
|
-
enqueue(card, column, labels, mode = "implement") {
|
|
34
|
-
const existing = this.items.findIndex((i) => i.cardId === card.id);
|
|
35
|
-
if (existing !== -1) {
|
|
36
|
-
log.debug(TAG, `Card #${card.short_id} already queued, updating priority`);
|
|
37
|
-
this.items.splice(existing, 1);
|
|
38
|
-
}
|
|
39
|
-
const priority = this.scoreCard(card, column, labels);
|
|
40
|
-
const item = {
|
|
41
|
-
cardId: card.id,
|
|
42
|
-
shortId: card.short_id,
|
|
43
|
-
title: card.title,
|
|
44
|
-
priority,
|
|
45
|
-
enqueuedAt: Date.now(),
|
|
46
|
-
mode,
|
|
47
|
-
};
|
|
48
|
-
// Insert in sorted position (highest priority first, FIFO tiebreak)
|
|
49
|
-
let insertIdx = this.items.length;
|
|
50
|
-
for (let i = 0; i < this.items.length; i++) {
|
|
51
|
-
if (priority > this.items[i].priority ||
|
|
52
|
-
(priority === this.items[i].priority &&
|
|
53
|
-
item.enqueuedAt < this.items[i].enqueuedAt)) {
|
|
54
|
-
insertIdx = i;
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
this.items.splice(insertIdx, 0, item);
|
|
59
|
-
log.info(TAG, `Enqueued #${card.short_id} "${card.title}" (priority=${priority}, pos=${insertIdx}, queue=${this.items.length})`);
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Remove and return the highest-priority item.
|
|
63
|
-
*/
|
|
64
|
-
dequeue() {
|
|
65
|
-
return this.items.shift() ?? null;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Remove a specific card from the queue.
|
|
69
|
-
*/
|
|
70
|
-
remove(cardId) {
|
|
71
|
-
const idx = this.items.findIndex((i) => i.cardId === cardId);
|
|
72
|
-
if (idx === -1)
|
|
73
|
-
return null;
|
|
74
|
-
const [item] = this.items.splice(idx, 1);
|
|
75
|
-
log.info(TAG, `Removed #${item.shortId} from queue`);
|
|
76
|
-
return item;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Check if a card is in the queue.
|
|
80
|
-
*/
|
|
81
|
-
has(cardId) {
|
|
82
|
-
return this.items.some((i) => i.cardId === cardId);
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Get all queued card IDs.
|
|
86
|
-
*/
|
|
87
|
-
cardIds() {
|
|
88
|
-
return this.items.map((i) => i.cardId);
|
|
89
|
-
}
|
|
90
|
-
get length() {
|
|
91
|
-
return this.items.length;
|
|
92
|
-
}
|
|
93
|
-
peek() {
|
|
94
|
-
return this.items[0] ?? null;
|
|
95
|
-
}
|
|
96
|
-
/** Copy of the queue in priority order (for introspection). */
|
|
97
|
-
snapshot() {
|
|
98
|
-
return this.items.slice();
|
|
99
|
-
}
|
|
100
|
-
}
|
package/dist/reconcile.d.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
-
import type { Pool } from "./pool.js";
|
|
3
|
-
import type { StateStore } from "./state-store.js";
|
|
4
|
-
import { type AgentConfig } from "./types.js";
|
|
5
|
-
/**
|
|
6
|
-
* Reconciliation heartbeat: polls the board every `intervalMs` to catch
|
|
7
|
-
* missed realtime events and sync state.
|
|
8
|
-
*/
|
|
9
|
-
export declare class Reconciler {
|
|
10
|
-
private client;
|
|
11
|
-
private pool;
|
|
12
|
-
private projectId;
|
|
13
|
-
private agentUserId;
|
|
14
|
-
private pickupColumns;
|
|
15
|
-
private reviewColumns;
|
|
16
|
-
private approvedLabel;
|
|
17
|
-
private intervalMs;
|
|
18
|
-
private stateStore?;
|
|
19
|
-
private agentConfig?;
|
|
20
|
-
private timer;
|
|
21
|
-
private lastTickAt;
|
|
22
|
-
get lastTick(): number | null;
|
|
23
|
-
get isRunning(): boolean;
|
|
24
|
-
constructor(client: HarmonyApiClient, pool: Pool, projectId: string, agentUserId: string, pickupColumns: string[], reviewColumns: string[], approvedLabel: string, intervalMs?: number, stateStore?: StateStore | undefined, agentConfig?: AgentConfig | undefined);
|
|
25
|
-
start(): void;
|
|
26
|
-
stop(): void;
|
|
27
|
-
/**
|
|
28
|
-
* Walk the state store for runs marked active whose owning daemon is
|
|
29
|
-
* dead OR whose heartbeat is stale. Each such run gets the same
|
|
30
|
-
* recovery treatment as startup orphans: session ended, card returned
|
|
31
|
-
* to pickup column with agent-recovered label, worktree cleaned up.
|
|
32
|
-
*/
|
|
33
|
-
private recoverStaleRuns;
|
|
34
|
-
private tick;
|
|
35
|
-
}
|
package/dist/reconcile.js
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import { buildLabelMap, hasLabel, resolveCardLabels } from "./board-helpers.js";
|
|
2
|
-
import { log } from "./log.js";
|
|
3
|
-
import { isProcessAlive, recoverRun } from "./recovery.js";
|
|
4
|
-
import { extractBranchFromDescription } from "./review-worktree.js";
|
|
5
|
-
import { NEED_REVIEW_LABEL } from "./types.js";
|
|
6
|
-
const TAG = "reconcile";
|
|
7
|
-
/**
|
|
8
|
-
* Reconciliation heartbeat: polls the board every `intervalMs` to catch
|
|
9
|
-
* missed realtime events and sync state.
|
|
10
|
-
*/
|
|
11
|
-
export class Reconciler {
|
|
12
|
-
client;
|
|
13
|
-
pool;
|
|
14
|
-
projectId;
|
|
15
|
-
agentUserId;
|
|
16
|
-
pickupColumns;
|
|
17
|
-
reviewColumns;
|
|
18
|
-
approvedLabel;
|
|
19
|
-
intervalMs;
|
|
20
|
-
stateStore;
|
|
21
|
-
agentConfig;
|
|
22
|
-
timer = null;
|
|
23
|
-
lastTickAt = null;
|
|
24
|
-
get lastTick() {
|
|
25
|
-
return this.lastTickAt;
|
|
26
|
-
}
|
|
27
|
-
get isRunning() {
|
|
28
|
-
return this.timer !== null;
|
|
29
|
-
}
|
|
30
|
-
constructor(client, pool, projectId, agentUserId, pickupColumns, reviewColumns, approvedLabel, intervalMs = 60_000, stateStore, agentConfig) {
|
|
31
|
-
this.client = client;
|
|
32
|
-
this.pool = pool;
|
|
33
|
-
this.projectId = projectId;
|
|
34
|
-
this.agentUserId = agentUserId;
|
|
35
|
-
this.pickupColumns = pickupColumns;
|
|
36
|
-
this.reviewColumns = reviewColumns;
|
|
37
|
-
this.approvedLabel = approvedLabel;
|
|
38
|
-
this.intervalMs = intervalMs;
|
|
39
|
-
this.stateStore = stateStore;
|
|
40
|
-
this.agentConfig = agentConfig;
|
|
41
|
-
}
|
|
42
|
-
start() {
|
|
43
|
-
// Run immediately, then on interval
|
|
44
|
-
this.tick();
|
|
45
|
-
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
|
46
|
-
}
|
|
47
|
-
stop() {
|
|
48
|
-
if (this.timer) {
|
|
49
|
-
clearInterval(this.timer);
|
|
50
|
-
this.timer = null;
|
|
51
|
-
}
|
|
52
|
-
log.info(TAG, "Heartbeat stopped");
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Walk the state store for runs marked active whose owning daemon is
|
|
56
|
-
* dead OR whose heartbeat is stale. Each such run gets the same
|
|
57
|
-
* recovery treatment as startup orphans: session ended, card returned
|
|
58
|
-
* to pickup column with agent-recovered label, worktree cleaned up.
|
|
59
|
-
*/
|
|
60
|
-
async recoverStaleRuns() {
|
|
61
|
-
if (!this.stateStore || !this.agentConfig)
|
|
62
|
-
return;
|
|
63
|
-
const now = Date.now();
|
|
64
|
-
const stale = this.agentConfig.timing.staleHeartbeatMs;
|
|
65
|
-
const active = this.stateStore.getActiveRuns();
|
|
66
|
-
const pool = this.pool;
|
|
67
|
-
for (const run of active) {
|
|
68
|
-
const foreignDaemon = run.daemonPid !== process.pid;
|
|
69
|
-
const daemonDead = foreignDaemon && !isProcessAlive(run.daemonPid, process.pid);
|
|
70
|
-
const heartbeatStale = now - run.lastHeartbeatAt > stale;
|
|
71
|
-
const ourZombie = !foreignDaemon && !pool.isCardActive(run.cardId);
|
|
72
|
-
if (!daemonDead && !(heartbeatStale && ourZombie))
|
|
73
|
-
continue;
|
|
74
|
-
const reason = daemonDead
|
|
75
|
-
? `foreign daemon ${run.daemonPid} is dead`
|
|
76
|
-
: `our worker lost card ${run.cardId} with ${Math.round((now - run.lastHeartbeatAt) / 1000)}s stale heartbeat`;
|
|
77
|
-
log.warn(TAG, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
|
|
78
|
-
await recoverRun(run, this.stateStore, this.client, this.agentConfig, {
|
|
79
|
-
runId: run.runId,
|
|
80
|
-
cardId: run.cardId,
|
|
81
|
-
cardShortId: run.cardShortId,
|
|
82
|
-
pipeline: run.pipeline,
|
|
83
|
-
actions: [],
|
|
84
|
-
errors: [],
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
async tick() {
|
|
89
|
-
this.lastTickAt = Date.now();
|
|
90
|
-
try {
|
|
91
|
-
const board = await this.client.getBoard(this.projectId);
|
|
92
|
-
const cards = (board.cards ?? []);
|
|
93
|
-
const columns = (board.columns ?? []);
|
|
94
|
-
// Build label lookup (id → Label) to resolve card.labelIds
|
|
95
|
-
const labelMap = buildLabelMap((board.labels ?? []));
|
|
96
|
-
// Build a lookup of columns by ID
|
|
97
|
-
const columnMap = new Map();
|
|
98
|
-
for (const col of columns) {
|
|
99
|
-
columnMap.set(col.id, col);
|
|
100
|
-
}
|
|
101
|
-
// Build column ID sets for both modes
|
|
102
|
-
const pickupColumnIds = new Set(columns
|
|
103
|
-
.filter((c) => this.pickupColumns.some((name) => name.toLowerCase() === c.name.toLowerCase()))
|
|
104
|
-
.map((c) => c.id));
|
|
105
|
-
const reviewColumnIds = new Set(columns
|
|
106
|
-
.filter((c) => this.reviewColumns.some((name) => name.toLowerCase() === c.name.toLowerCase()))
|
|
107
|
-
.map((c) => c.id));
|
|
108
|
-
// Find cards assigned to our agent in either pickup or review columns
|
|
109
|
-
const assignedCards = cards.filter((c) => c.assignee_id === this.agentUserId &&
|
|
110
|
-
!c.archived_at &&
|
|
111
|
-
(pickupColumnIds.has(c.column_id) ||
|
|
112
|
-
reviewColumnIds.has(c.column_id)));
|
|
113
|
-
const knownCardIds = this.pool.knownCardIds();
|
|
114
|
-
// All cards still assigned to the agent (any column) — used to detect
|
|
115
|
-
// genuine unassigns without false-positiving on cards the worker moved
|
|
116
|
-
// to "In Progress" or other non-pickup columns.
|
|
117
|
-
const allAgentCardIds = new Set(cards
|
|
118
|
-
.filter((c) => c.assignee_id === this.agentUserId && !c.archived_at)
|
|
119
|
-
.map((c) => c.id));
|
|
120
|
-
// Cards assigned but NOT in queue/active → enqueue (missed event)
|
|
121
|
-
for (const card of assignedCards) {
|
|
122
|
-
if (!knownCardIds.has(card.id)) {
|
|
123
|
-
const column = columnMap.get(card.column_id);
|
|
124
|
-
if (!column)
|
|
125
|
-
continue;
|
|
126
|
-
const cardLabels = resolveCardLabels(card, labelMap);
|
|
127
|
-
const subtasks = card.subtasks ?? [];
|
|
128
|
-
// Determine mode based on which column set the card is in
|
|
129
|
-
const mode = reviewColumnIds.has(card.column_id)
|
|
130
|
-
? "review"
|
|
131
|
-
: "implement";
|
|
132
|
-
// Skip already-approved cards in review mode
|
|
133
|
-
if (mode === "review" &&
|
|
134
|
-
this.approvedLabel &&
|
|
135
|
-
hasLabel(cardLabels, this.approvedLabel)) {
|
|
136
|
-
log.debug(TAG, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
// Skip cards with "Need Review" label (awaiting human review)
|
|
140
|
-
if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
|
|
141
|
-
log.debug(TAG, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
// Skip review for cards without a branch reference — not qualified for auto-review
|
|
145
|
-
if (mode === "review" &&
|
|
146
|
-
!extractBranchFromDescription(card.description)) {
|
|
147
|
-
log.debug(TAG, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
log.info(TAG, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
|
|
151
|
-
await this.pool.enqueue(card, column, cardLabels, subtasks, mode);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// Detect zombie runs: state-store says active, but either:
|
|
155
|
-
// (a) another daemon's PID is dead, or
|
|
156
|
-
// (b) our daemon holds the run but no worker is on the card, or
|
|
157
|
-
// (c) heartbeat is older than staleHeartbeatMs.
|
|
158
|
-
if (this.stateStore && this.agentConfig) {
|
|
159
|
-
await this.recoverStaleRuns();
|
|
160
|
-
}
|
|
161
|
-
// Cards in queue/active but no longer assigned to agent → cancel/remove
|
|
162
|
-
for (const knownId of knownCardIds) {
|
|
163
|
-
if (!allAgentCardIds.has(knownId)) {
|
|
164
|
-
log.info(TAG, `Missed unassign: ${knownId} — removing`);
|
|
165
|
-
await this.pool.removeCard(knownId);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
log.debug(TAG, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
|
|
169
|
-
}
|
|
170
|
-
catch (err) {
|
|
171
|
-
log.error(TAG, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
package/dist/recovery.d.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
-
import type { RunRecord, StateStore } from "./state-store.js";
|
|
3
|
-
import type { AgentConfig } from "./types.js";
|
|
4
|
-
export interface RecoveryOutcome {
|
|
5
|
-
runId: string;
|
|
6
|
-
cardId: string;
|
|
7
|
-
cardShortId: number;
|
|
8
|
-
pipeline: "implement" | "review";
|
|
9
|
-
actions: string[];
|
|
10
|
-
errors: string[];
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* Check if a process is still alive. A crashed daemon's PID is unlikely
|
|
14
|
-
* to be reused within a reboot window; if it is, we still treat it as
|
|
15
|
-
* orphaned because our current process is the new daemon.
|
|
16
|
-
*/
|
|
17
|
-
export declare function isProcessAlive(pid: number, currentPid: number): boolean;
|
|
18
|
-
/**
|
|
19
|
-
* Reconcile orphaned runs from a previous daemon life.
|
|
20
|
-
*
|
|
21
|
-
* For each active run in the state store:
|
|
22
|
-
* - If the daemon PID is alive (should not happen for a fresh process),
|
|
23
|
-
* skip it — it's another instance.
|
|
24
|
-
* - Otherwise: end the Harmony session, return the card to its pickup
|
|
25
|
-
* column with the `agent-recovered` label, and cleanup the worktree.
|
|
26
|
-
*
|
|
27
|
-
* This runs once at daemon startup, before the pool accepts work.
|
|
28
|
-
*/
|
|
29
|
-
export declare function recoverOrphans(store: StateStore, client: HarmonyApiClient, config: AgentConfig): Promise<RecoveryOutcome[]>;
|
|
30
|
-
export declare function recoverRun(run: RunRecord, store: StateStore, client: HarmonyApiClient, config: AgentConfig, outcome: RecoveryOutcome): Promise<void>;
|
package/dist/recovery.js
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
|
|
2
|
-
import { log } from "./log.js";
|
|
3
|
-
import { cleanupWorktree } from "./worktree.js";
|
|
4
|
-
const TAG = "recovery";
|
|
5
|
-
const RECOVERED_LABEL = "agent-recovered";
|
|
6
|
-
const RECOVERED_LABEL_COLOR = "#f59e0b";
|
|
7
|
-
/**
|
|
8
|
-
* Check if a process is still alive. A crashed daemon's PID is unlikely
|
|
9
|
-
* to be reused within a reboot window; if it is, we still treat it as
|
|
10
|
-
* orphaned because our current process is the new daemon.
|
|
11
|
-
*/
|
|
12
|
-
export function isProcessAlive(pid, currentPid) {
|
|
13
|
-
if (pid === currentPid)
|
|
14
|
-
return true;
|
|
15
|
-
try {
|
|
16
|
-
process.kill(pid, 0);
|
|
17
|
-
return true;
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
async function fetchCardSafely(client, cardId) {
|
|
24
|
-
try {
|
|
25
|
-
const { card } = (await client.getCard(cardId));
|
|
26
|
-
return card;
|
|
27
|
-
}
|
|
28
|
-
catch (err) {
|
|
29
|
-
log.warn(TAG, `cannot fetch card ${cardId}: ${err instanceof Error ? err.message : err}`);
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Reconcile orphaned runs from a previous daemon life.
|
|
35
|
-
*
|
|
36
|
-
* For each active run in the state store:
|
|
37
|
-
* - If the daemon PID is alive (should not happen for a fresh process),
|
|
38
|
-
* skip it — it's another instance.
|
|
39
|
-
* - Otherwise: end the Harmony session, return the card to its pickup
|
|
40
|
-
* column with the `agent-recovered` label, and cleanup the worktree.
|
|
41
|
-
*
|
|
42
|
-
* This runs once at daemon startup, before the pool accepts work.
|
|
43
|
-
*/
|
|
44
|
-
export async function recoverOrphans(store, client, config) {
|
|
45
|
-
const active = store.getActiveRuns();
|
|
46
|
-
if (active.length === 0) {
|
|
47
|
-
return [];
|
|
48
|
-
}
|
|
49
|
-
const outcomes = [];
|
|
50
|
-
log.info(TAG, `recovering ${active.length} orphan run(s) from prior daemon`);
|
|
51
|
-
for (const run of active) {
|
|
52
|
-
const outcome = {
|
|
53
|
-
runId: run.runId,
|
|
54
|
-
cardId: run.cardId,
|
|
55
|
-
cardShortId: run.cardShortId,
|
|
56
|
-
pipeline: run.pipeline,
|
|
57
|
-
actions: [],
|
|
58
|
-
errors: [],
|
|
59
|
-
};
|
|
60
|
-
outcomes.push(outcome);
|
|
61
|
-
if (isProcessAlive(run.daemonPid, process.pid)) {
|
|
62
|
-
log.warn(TAG, `run ${run.runId} claims live daemon pid ${run.daemonPid} — skipping`);
|
|
63
|
-
outcome.actions.push("skipped: daemon pid still alive");
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
log.info(TAG, `recovering ${run.pipeline} run ${run.runId} for card #${run.cardShortId}`);
|
|
67
|
-
await recoverRun(run, store, client, config, outcome);
|
|
68
|
-
}
|
|
69
|
-
return outcomes;
|
|
70
|
-
}
|
|
71
|
-
export async function recoverRun(run, store, client, config, outcome) {
|
|
72
|
-
// 1. End the agent session so the card stops showing the progress ring.
|
|
73
|
-
// Mark as failed (not paused) — daemon crash is an outcome, not a
|
|
74
|
-
// user-initiated pause. UI renders the destructive tint + recovery branch
|
|
75
|
-
// button if the run had pushed any commits.
|
|
76
|
-
try {
|
|
77
|
-
await client.endAgentSession(run.cardId, {
|
|
78
|
-
status: "failed",
|
|
79
|
-
failureReason: "daemon_restart",
|
|
80
|
-
failureSummary: `Daemon restarted mid-${run.pipeline} (phase: ${run.phase})`,
|
|
81
|
-
recoveryBranch: run.branchName ?? undefined,
|
|
82
|
-
progressPercent: run.phase === "completing" ? 95 : undefined,
|
|
83
|
-
});
|
|
84
|
-
outcome.actions.push("ended agent session (failed: daemon_restart)");
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
88
|
-
outcome.errors.push(`endAgentSession: ${msg}`);
|
|
89
|
-
log.warn(TAG, `endAgentSession failed for ${run.cardId}: ${msg}`);
|
|
90
|
-
}
|
|
91
|
-
// 2. Move card back to a safe column and add the recovered label.
|
|
92
|
-
// - implement pipeline → pickup column (usually "To Do")
|
|
93
|
-
// - review pipeline → leave in place (reviewer will re-pick)
|
|
94
|
-
const card = await fetchCardSafely(client, run.cardId);
|
|
95
|
-
if (card) {
|
|
96
|
-
if (run.pipeline === "implement") {
|
|
97
|
-
const target = config.pickupColumns[0];
|
|
98
|
-
if (target) {
|
|
99
|
-
try {
|
|
100
|
-
await moveCardToColumn(client, card, target);
|
|
101
|
-
outcome.actions.push(`moved to "${target}"`);
|
|
102
|
-
}
|
|
103
|
-
catch (err) {
|
|
104
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
105
|
-
outcome.errors.push(`moveCardToColumn: ${msg}`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
try {
|
|
110
|
-
await addLabelByName(client, card, RECOVERED_LABEL, RECOVERED_LABEL_COLOR);
|
|
111
|
-
outcome.actions.push(`labeled "${RECOVERED_LABEL}"`);
|
|
112
|
-
}
|
|
113
|
-
catch (err) {
|
|
114
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
115
|
-
outcome.errors.push(`addLabel: ${msg}`);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
outcome.actions.push("card not reachable — local cleanup only");
|
|
120
|
-
}
|
|
121
|
-
// 3. Cleanup local worktree so it doesn't collide with future runs.
|
|
122
|
-
if (run.worktreePath) {
|
|
123
|
-
try {
|
|
124
|
-
cleanupWorktree(run.worktreePath, run.branchName ?? undefined);
|
|
125
|
-
outcome.actions.push("cleaned up worktree");
|
|
126
|
-
}
|
|
127
|
-
catch (err) {
|
|
128
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
129
|
-
outcome.errors.push(`cleanupWorktree: ${msg}`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
// 4. Mark the run as orphaned in the store.
|
|
133
|
-
try {
|
|
134
|
-
await store.endRun(run.runId, "orphaned", "recovered after daemon restart");
|
|
135
|
-
}
|
|
136
|
-
catch (err) {
|
|
137
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
138
|
-
outcome.errors.push(`endRun: ${msg}`);
|
|
139
|
-
}
|
|
140
|
-
log.info(TAG, `recovered run ${run.runId} (card #${run.cardShortId}): ${outcome.actions.join(", ")}${outcome.errors.length ? ` | errors: ${outcome.errors.join("; ")}` : ""}`);
|
|
141
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
|
-
import type { Card } from "@harmony/shared";
|
|
3
|
-
import { type SessionStats } from "./completion.js";
|
|
4
|
-
import type { StateStore } from "./state-store.js";
|
|
5
|
-
import { type AgentConfig } from "./types.js";
|
|
6
|
-
export interface ReviewFinding {
|
|
7
|
-
severity: "critical" | "major" | "minor";
|
|
8
|
-
title: string;
|
|
9
|
-
description: string;
|
|
10
|
-
category?: string;
|
|
11
|
-
location?: string;
|
|
12
|
-
}
|
|
13
|
-
export interface ScopeCheck {
|
|
14
|
-
status: "clean" | "drift" | "missing";
|
|
15
|
-
notes?: string;
|
|
16
|
-
}
|
|
17
|
-
export interface ReviewResult {
|
|
18
|
-
verdict: "approved" | "rejected" | "error";
|
|
19
|
-
summary: string;
|
|
20
|
-
scopeCheck?: ScopeCheck;
|
|
21
|
-
findings: ReviewFinding[];
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Parse Claude's review output into a structured ReviewResult.
|
|
25
|
-
*
|
|
26
|
-
* Tries multiple extraction strategies in order:
|
|
27
|
-
* 1. ```json ... ``` fenced block (what the prompt asks for)
|
|
28
|
-
* 2. Any top-level JSON object containing a "verdict" key (last-wins)
|
|
29
|
-
* 3. Regex for a bare `"verdict": "approved|rejected"` anywhere — lossy
|
|
30
|
-
* but keeps the pipeline moving
|
|
31
|
-
* 4. Falls back to verdict: "error" — keeps card in Review instead of
|
|
32
|
-
* bouncing it to To Do for a parse failure that isn't a code quality signal.
|
|
33
|
-
*/
|
|
34
|
-
export declare function parseReviewOutput(stdout: string): ReviewResult;
|
|
35
|
-
export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats: SessionStats | null | undefined, runLogPath: string | null | undefined, workspaceId: string | undefined, agentSessionId: string | null | undefined, stateStore: StateStore): Promise<void>;
|