@gethmy/agent 1.4.2 → 1.6.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/budget.d.ts +4 -2
- package/dist/budget.js +41 -2
- package/dist/completion.d.ts +4 -1
- package/dist/completion.js +102 -8
- package/dist/episode-writer.d.ts +84 -0
- package/dist/episode-writer.js +232 -0
- package/dist/git-pr.d.ts +13 -0
- package/dist/git-pr.js +73 -0
- package/dist/index.js +6 -1
- package/dist/pool.d.ts +6 -1
- package/dist/pool.js +39 -5
- package/dist/progress-tracker.d.ts +2 -0
- package/dist/progress-tracker.js +7 -0
- package/dist/prompt.d.ts +6 -0
- package/dist/prompt.js +47 -2
- package/dist/recovery.js +8 -2
- package/dist/review-completion.d.ts +2 -1
- package/dist/review-completion.js +84 -5
- package/dist/review-worker.d.ts +3 -1
- package/dist/review-worker.js +30 -3
- package/dist/state-store.d.ts +16 -0
- package/dist/state-store.js +23 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.js +3 -0
- package/dist/verification.d.ts +7 -1
- package/dist/verification.js +6 -1
- package/dist/worker.js +2 -2
- package/dist/worktree-gc.d.ts +29 -1
- package/dist/worktree-gc.js +108 -1
- package/dist/worktree.d.ts +6 -1
- package/dist/worktree.js +7 -2
- package/package.json +1 -1
package/dist/git-pr.js
CHANGED
|
@@ -167,6 +167,79 @@ export function pushBranch(branchName, cwd) {
|
|
|
167
167
|
});
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Push the current branch's tip to `newRef` on origin and delete `oldRef`.
|
|
172
|
+
* Used when an approved attempt graduates from `agent-attempts/*` to
|
|
173
|
+
* `agent/*` — keeps the commits durable across the rename and avoids any
|
|
174
|
+
* window where the work is unreachable on origin.
|
|
175
|
+
*/
|
|
176
|
+
export function renameRemoteBranch(oldRef, newRef, cwd) {
|
|
177
|
+
if (oldRef === newRef)
|
|
178
|
+
return;
|
|
179
|
+
let sha;
|
|
180
|
+
try {
|
|
181
|
+
sha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
182
|
+
cwd,
|
|
183
|
+
encoding: "utf-8",
|
|
184
|
+
}).trim();
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
throw new Error(`renameRemoteBranch: could not resolve HEAD: ${err instanceof Error ? err.message : err}`);
|
|
188
|
+
}
|
|
189
|
+
log.info(TAG, `Renaming remote ${oldRef} → ${newRef}`);
|
|
190
|
+
execFileSync("git", ["push", "origin", `${sha}:refs/heads/${newRef}`, "--force-with-lease"], { cwd, stdio: "pipe" });
|
|
191
|
+
try {
|
|
192
|
+
execFileSync("git", ["push", "origin", `:refs/heads/${oldRef}`], {
|
|
193
|
+
cwd,
|
|
194
|
+
stdio: "pipe",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
log.warn(TAG, `renameRemoteBranch: could not delete old ref ${oldRef}: ${err instanceof Error ? err.message : err}`);
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
execFileSync("git", ["branch", "-m", oldRef, newRef], {
|
|
202
|
+
cwd,
|
|
203
|
+
stdio: "pipe",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Worktree may not have the old branch checked out — non-fatal.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Best-effort public branch URL for the recovery button on a failed session.
|
|
212
|
+
* Returns null when we can't infer a tree URL — the daemon falls back to a
|
|
213
|
+
* plain `git fetch && git checkout <ref>` instruction in that case.
|
|
214
|
+
*/
|
|
215
|
+
export function getBranchWebUrl(branchName, cwd) {
|
|
216
|
+
try {
|
|
217
|
+
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
218
|
+
cwd,
|
|
219
|
+
encoding: "utf-8",
|
|
220
|
+
}).trim();
|
|
221
|
+
const encoded = branchName.split("/").map(encodeURIComponent).join("/");
|
|
222
|
+
if (/github\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
|
|
223
|
+
const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
224
|
+
if (m)
|
|
225
|
+
return `https://github.com/${m[1]}/${m[2]}/tree/${encoded}`;
|
|
226
|
+
}
|
|
227
|
+
if (/gitlab\.com[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
|
|
228
|
+
const m = remoteUrl.match(/gitlab\.com[:/](.+?)(?:\.git)?$/);
|
|
229
|
+
if (m)
|
|
230
|
+
return `https://gitlab.com/${m[1]}/-/tree/${encoded}`;
|
|
231
|
+
}
|
|
232
|
+
if (/bitbucket\.org[:/]([^/]+)\/([^/.]+)/.test(remoteUrl)) {
|
|
233
|
+
const m = remoteUrl.match(/bitbucket\.org[:/](.+?)(?:\.git)?$/);
|
|
234
|
+
if (m)
|
|
235
|
+
return `https://bitbucket.org/${m[1]}/branch/${encoded}`;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
170
243
|
// ============ PR CREATION (PROVIDER-AWARE) ============
|
|
171
244
|
export function buildPrBody(card, commitLog) {
|
|
172
245
|
return [
|
package/dist/index.js
CHANGED
|
@@ -146,7 +146,12 @@ export async function main() {
|
|
|
146
146
|
}
|
|
147
147
|
// Periodic worktree GC — removes orphan worktrees older than 1h that
|
|
148
148
|
// no active run claims. Covers crashes that left worktrees behind.
|
|
149
|
-
const worktreeGc = new WorktreeGc(config.agent.worktree.basePath, stateStore, config.agent.timing.worktreeGcIntervalMs
|
|
149
|
+
const worktreeGc = new WorktreeGc(config.agent.worktree.basePath, stateStore, config.agent.timing.worktreeGcIntervalMs, config.agent.worktree.failedAttemptRetentionDays > 0
|
|
150
|
+
? {
|
|
151
|
+
prefix: config.agent.worktree.failedBranchPrefix,
|
|
152
|
+
retentionDays: config.agent.worktree.failedAttemptRetentionDays,
|
|
153
|
+
}
|
|
154
|
+
: undefined);
|
|
150
155
|
// Local status/control HTTP server.
|
|
151
156
|
const startedAt = Date.now();
|
|
152
157
|
const httpServer = config.agent.http.enabled
|
package/dist/pool.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
|
2
2
|
import type { Card, Column, Label, Subtask } from "@harmony/shared";
|
|
3
3
|
import { PriorityQueue } from "./queue.js";
|
|
4
4
|
import type { StateStore } from "./state-store.js";
|
|
5
|
-
import type
|
|
5
|
+
import { type AgentConfig, type WorkMode } from "./types.js";
|
|
6
6
|
export declare class Pool {
|
|
7
7
|
private client;
|
|
8
8
|
private implWorkers;
|
|
@@ -19,6 +19,11 @@ export declare class Pool {
|
|
|
19
19
|
* and manual API calls all go through the same gate.
|
|
20
20
|
*/
|
|
21
21
|
enqueue(card: Card, column: Column, labels: Label[], subtasks: Subtask[], mode?: WorkMode): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Best-effort waiting-state emit. Failures are swallowed because we don't
|
|
24
|
+
* want a board-API hiccup to drop the queue/budget event in pool.ts.
|
|
25
|
+
*/
|
|
26
|
+
private emitWaiting;
|
|
22
27
|
/**
|
|
23
28
|
* Remove a card from any queue or cancel an active worker.
|
|
24
29
|
*/
|
package/dist/pool.js
CHANGED
|
@@ -2,6 +2,7 @@ import { BudgetGuard } from "./budget.js";
|
|
|
2
2
|
import { log } from "./log.js";
|
|
3
3
|
import { PriorityQueue } from "./queue.js";
|
|
4
4
|
import { ReviewWorker } from "./review-worker.js";
|
|
5
|
+
import { AGENT_NAME, agentIdentifier, } from "./types.js";
|
|
5
6
|
import { Worker } from "./worker.js";
|
|
6
7
|
const TAG = "pool";
|
|
7
8
|
export class Pool {
|
|
@@ -27,7 +28,7 @@ export class Pool {
|
|
|
27
28
|
const reviewWorkerId = config.poolSize; // offset to avoid ID collision
|
|
28
29
|
this.reviewWorkers.push(new ReviewWorker(reviewWorkerId, config, client, userEmail, () => {
|
|
29
30
|
this.tryDispatchFor(this.reviewWorkers, this.reviewQueue, "review");
|
|
30
|
-
}, stateStore));
|
|
31
|
+
}, stateStore, workspaceId, projectId));
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
@@ -58,6 +59,11 @@ export class Pool {
|
|
|
58
59
|
if (this.budget.isTerminal(decision.reason)) {
|
|
59
60
|
await this.budget.markDlq(this.client, card, decision.reason, decision.detail);
|
|
60
61
|
}
|
|
62
|
+
else if (decision.reason === "daily_budget") {
|
|
63
|
+
// Soft pause — surface as `waiting` so the board signals that
|
|
64
|
+
// the daemon will pick this card up after the budget resets.
|
|
65
|
+
await this.emitWaiting(card.id, `Daily budget reached — waiting for reset (${decision.detail})`);
|
|
66
|
+
}
|
|
61
67
|
}
|
|
62
68
|
else {
|
|
63
69
|
log.debug(TAG, `#${card.short_id} in DLQ: ${decision.detail}`);
|
|
@@ -70,7 +76,34 @@ export class Pool {
|
|
|
70
76
|
// Store card data for when it gets dispatched
|
|
71
77
|
this.cardDataCache.set(card.id, { card, column, labels, subtasks, mode });
|
|
72
78
|
const workers = mode === "review" ? this.reviewWorkers : this.implWorkers;
|
|
73
|
-
this.tryDispatchFor(workers, queue, mode);
|
|
79
|
+
const dispatched = this.tryDispatchFor(workers, queue, mode);
|
|
80
|
+
if (!dispatched) {
|
|
81
|
+
// No idle worker — the card will wait in the queue. Surface the
|
|
82
|
+
// queue position so the board ribbon shows the user something is
|
|
83
|
+
// happening even though no worker has the card yet.
|
|
84
|
+
const position = queue.cardIds().indexOf(card.id) + 1;
|
|
85
|
+
const total = queue.length;
|
|
86
|
+
await this.emitWaiting(card.id, position > 0
|
|
87
|
+
? `Queued (${position}/${total}) — waiting for ${mode} worker`
|
|
88
|
+
: `Queued — waiting for ${mode} worker`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Best-effort waiting-state emit. Failures are swallowed because we don't
|
|
93
|
+
* want a board-API hiccup to drop the queue/budget event in pool.ts.
|
|
94
|
+
*/
|
|
95
|
+
async emitWaiting(cardId, currentTask) {
|
|
96
|
+
try {
|
|
97
|
+
await this.client.updateAgentProgress(cardId, {
|
|
98
|
+
agentIdentifier: agentIdentifier(0),
|
|
99
|
+
agentName: AGENT_NAME,
|
|
100
|
+
status: "waiting",
|
|
101
|
+
currentTask,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
log.debug(TAG, `waiting emit failed for ${cardId}: ${err instanceof Error ? err.message : err}`);
|
|
106
|
+
}
|
|
74
107
|
}
|
|
75
108
|
/**
|
|
76
109
|
* Remove a card from any queue or cancel an active worker.
|
|
@@ -203,18 +236,19 @@ export class Pool {
|
|
|
203
236
|
const idle = workers.find((w) => w.isIdle);
|
|
204
237
|
if (!idle) {
|
|
205
238
|
log.debug(TAG, `No idle ${label} workers (queue: ${queue.length})`);
|
|
206
|
-
return;
|
|
239
|
+
return false;
|
|
207
240
|
}
|
|
208
241
|
const next = queue.dequeue();
|
|
209
242
|
if (!next)
|
|
210
|
-
return;
|
|
243
|
+
return false;
|
|
211
244
|
const data = this.cardDataCache.get(next.cardId);
|
|
212
245
|
if (!data) {
|
|
213
246
|
log.warn(TAG, `No cached data for card ${next.cardId}, skipping`);
|
|
214
|
-
return;
|
|
247
|
+
return false;
|
|
215
248
|
}
|
|
216
249
|
this.cardDataCache.delete(next.cardId);
|
|
217
250
|
log.info(TAG, `Dispatching #${next.shortId} to ${label} worker ${idle.id}`);
|
|
218
251
|
idle.run(data.card, data.column, data.labels, data.subtasks);
|
|
252
|
+
return true;
|
|
219
253
|
}
|
|
220
254
|
}
|
|
@@ -30,6 +30,7 @@ export declare class ProgressTracker {
|
|
|
30
30
|
private lastCost;
|
|
31
31
|
private logBuffer;
|
|
32
32
|
private sessionId;
|
|
33
|
+
private lastAssistantText;
|
|
33
34
|
constructor(client: HarmonyApiClient, cardId: string, workerId: number, subtasks: {
|
|
34
35
|
completed: boolean;
|
|
35
36
|
}[]);
|
|
@@ -48,6 +49,7 @@ export declare class ProgressTracker {
|
|
|
48
49
|
filesRead: number;
|
|
49
50
|
toolCalls: number;
|
|
50
51
|
cost: CostUpdate | null;
|
|
52
|
+
lastAssistantText: string;
|
|
51
53
|
};
|
|
52
54
|
private onToolStart;
|
|
53
55
|
private onToolEnd;
|
package/dist/progress-tracker.js
CHANGED
|
@@ -62,6 +62,9 @@ export class ProgressTracker {
|
|
|
62
62
|
lastCost = null;
|
|
63
63
|
logBuffer = [];
|
|
64
64
|
sessionId = null;
|
|
65
|
+
// Last assistant text block — used by the episode write hook to
|
|
66
|
+
// capture an approach summary without re-running an LLM (plan §"Write hook").
|
|
67
|
+
lastAssistantText = "";
|
|
65
68
|
constructor(client, cardId, workerId, subtasks) {
|
|
66
69
|
this.client = client;
|
|
67
70
|
this.cardId = cardId;
|
|
@@ -129,6 +132,7 @@ export class ProgressTracker {
|
|
|
129
132
|
filesRead: this.filesRead.size,
|
|
130
133
|
toolCalls: this.toolCallCount,
|
|
131
134
|
cost: this.lastCost,
|
|
135
|
+
lastAssistantText: this.lastAssistantText,
|
|
132
136
|
};
|
|
133
137
|
}
|
|
134
138
|
onToolStart(name, input) {
|
|
@@ -205,6 +209,9 @@ export class ProgressTracker {
|
|
|
205
209
|
const trimmed = content.trim();
|
|
206
210
|
if (trimmed.length < 10)
|
|
207
211
|
return;
|
|
212
|
+
// Always remember the latest non-trivial assistant turn for the episode
|
|
213
|
+
// write hook — last-turn trim, no LLM rewrite (plan §"Write hook").
|
|
214
|
+
this.lastAssistantText = trimmed;
|
|
208
215
|
// Extract first sentence or line as a brief description
|
|
209
216
|
const end = trimmed.search(SENTENCE_SPLIT);
|
|
210
217
|
const firstLine = (end === -1 ? trimmed : trimmed.slice(0, end)).trim();
|
package/dist/prompt.d.ts
CHANGED
|
@@ -10,3 +10,9 @@ import type { EnrichedCard } from "./types.js";
|
|
|
10
10
|
* Falls back to a minimal local prompt if the API call fails.
|
|
11
11
|
*/
|
|
12
12
|
export declare function buildPrompt(enriched: EnrichedCard, branchName: string, worktreePath: string, client: HarmonyApiClient, workspaceId: string, projectId?: string): Promise<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Recall similar past episodes (implement solution/error type) and render them
|
|
15
|
+
* as a "Similar past tasks" section. Returns the empty string on no hits or
|
|
16
|
+
* recall failure — never throws.
|
|
17
|
+
*/
|
|
18
|
+
export declare function renderPastEpisodesSection(client: HarmonyApiClient, title: string, description: string, workspaceId: string, projectId?: string): Promise<string>;
|
package/dist/prompt.js
CHANGED
|
@@ -11,6 +11,10 @@ const TAG = "prompt";
|
|
|
11
11
|
*/
|
|
12
12
|
export async function buildPrompt(enriched, branchName, worktreePath, client, workspaceId, projectId) {
|
|
13
13
|
const { card } = enriched;
|
|
14
|
+
// Phase 1.5 read hook: surface similar past episodes for this card. Block
|
|
15
|
+
// on recall — v2 §6.3 budget already caps latency. Errors degrade silently
|
|
16
|
+
// so prompt build always succeeds (plan §"Read hook").
|
|
17
|
+
const pastEpisodesSection = await renderPastEpisodesSection(client, card.title, card.description ?? "", workspaceId, projectId);
|
|
14
18
|
try {
|
|
15
19
|
const result = await client.generateCardPrompt({
|
|
16
20
|
cardId: card.id,
|
|
@@ -22,12 +26,53 @@ Do NOT push to main. All your work stays on \`${branchName}\`.
|
|
|
22
26
|
When finished, call harmony_end_agent_session with status="completed".`,
|
|
23
27
|
});
|
|
24
28
|
log.info(TAG, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
|
|
25
|
-
return result.prompt;
|
|
29
|
+
return result.prompt + pastEpisodesSection;
|
|
26
30
|
}
|
|
27
31
|
catch (err) {
|
|
28
32
|
const msg = err instanceof Error ? err.message : String(err);
|
|
29
33
|
log.warn(TAG, `Failed to generate prompt via API, using fallback: ${msg}`);
|
|
30
|
-
return buildFallbackPrompt(enriched, branchName, worktreePath)
|
|
34
|
+
return (buildFallbackPrompt(enriched, branchName, worktreePath) +
|
|
35
|
+
pastEpisodesSection);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Recall similar past episodes (implement solution/error type) and render them
|
|
40
|
+
* as a "Similar past tasks" section. Returns the empty string on no hits or
|
|
41
|
+
* recall failure — never throws.
|
|
42
|
+
*/
|
|
43
|
+
export async function renderPastEpisodesSection(client, title, description, workspaceId, projectId) {
|
|
44
|
+
if (!projectId)
|
|
45
|
+
return "";
|
|
46
|
+
try {
|
|
47
|
+
const query = `${title}\n${description}`.trim();
|
|
48
|
+
const { entities } = await client.harmonyRecall({
|
|
49
|
+
workspaceId,
|
|
50
|
+
projectId,
|
|
51
|
+
query,
|
|
52
|
+
type: ["solution", "error"],
|
|
53
|
+
memory_tier: "episode",
|
|
54
|
+
scope: "project",
|
|
55
|
+
topK: 3,
|
|
56
|
+
});
|
|
57
|
+
if (entities.length === 0)
|
|
58
|
+
return "";
|
|
59
|
+
const bullets = entities
|
|
60
|
+
.map((entity) => {
|
|
61
|
+
const e = entity;
|
|
62
|
+
const meta = e.metadata ?? {};
|
|
63
|
+
const outcomeTag = meta.outcome ? `[${meta.outcome}]` : "[?]";
|
|
64
|
+
const approach = meta.approach_summary ?? "";
|
|
65
|
+
return `- ${outcomeTag} ${e.title ?? "(untitled episode)"}\n Approach: ${approach}`;
|
|
66
|
+
})
|
|
67
|
+
.join("\n");
|
|
68
|
+
return `\n\n## Similar past tasks\n${bullets}`;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
log.warn(TAG, "past-episodes recall failed", {
|
|
72
|
+
event: "episode_recall_failed",
|
|
73
|
+
error: err instanceof Error ? err.message : String(err),
|
|
74
|
+
});
|
|
75
|
+
return "";
|
|
31
76
|
}
|
|
32
77
|
}
|
|
33
78
|
/**
|
package/dist/recovery.js
CHANGED
|
@@ -70,12 +70,18 @@ export async function recoverOrphans(store, client, config) {
|
|
|
70
70
|
}
|
|
71
71
|
export async function recoverRun(run, store, client, config, outcome) {
|
|
72
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.
|
|
73
76
|
try {
|
|
74
77
|
await client.endAgentSession(run.cardId, {
|
|
75
|
-
status: "
|
|
78
|
+
status: "failed",
|
|
79
|
+
failureReason: "daemon_restart",
|
|
80
|
+
failureSummary: `Daemon restarted mid-${run.pipeline} (phase: ${run.phase})`,
|
|
81
|
+
recoveryBranch: run.branchName ?? undefined,
|
|
76
82
|
progressPercent: run.phase === "completing" ? 95 : undefined,
|
|
77
83
|
});
|
|
78
|
-
outcome.actions.push("ended agent session (
|
|
84
|
+
outcome.actions.push("ended agent session (failed: daemon_restart)");
|
|
79
85
|
}
|
|
80
86
|
catch (err) {
|
|
81
87
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
2
|
import type { Card } from "@harmony/shared";
|
|
3
3
|
import { type SessionStats } from "./completion.js";
|
|
4
|
+
import type { StateStore } from "./state-store.js";
|
|
4
5
|
import { type AgentConfig } from "./types.js";
|
|
5
6
|
export interface ReviewFinding {
|
|
6
7
|
severity: "critical" | "major" | "minor";
|
|
@@ -36,4 +37,4 @@ export declare function parseReviewOutput(stdout: string): ReviewResult;
|
|
|
36
37
|
* Handles approved/rejected verdicts, creates subtasks for findings,
|
|
37
38
|
* and moves the card to the appropriate column.
|
|
38
39
|
*/
|
|
39
|
-
export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats?: SessionStats | null, runLogPath?: string | null): Promise<void>;
|
|
40
|
+
export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats?: SessionStats | null, runLogPath?: string | null, workspaceId?: string, agentSessionId?: string | null, stateStore?: StateStore): Promise<void>;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
|
|
3
3
|
import { buildTokenPayload } from "./completion.js";
|
|
4
|
-
import {
|
|
4
|
+
import { backfillReviewVerdict, findLatestImplementEpisode, writeEpisode, } from "./episode-writer.js";
|
|
5
|
+
import { createPullRequest, detectGitProvider, getBranchWebUrl, pushBranch, renameRemoteBranch, } from "./git-pr.js";
|
|
5
6
|
import { log } from "./log.js";
|
|
6
7
|
import { NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR, } from "./types.js";
|
|
7
8
|
import { cleanupWorktree } from "./worktree.js";
|
|
@@ -182,7 +183,7 @@ function stripReviewSummary(description) {
|
|
|
182
183
|
* Handles approved/rejected verdicts, creates subtasks for findings,
|
|
183
184
|
* and moves the card to the appropriate column.
|
|
184
185
|
*/
|
|
185
|
-
export async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath) {
|
|
186
|
+
export async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath, workspaceId, agentSessionId, stateStore) {
|
|
186
187
|
// Re-fetch card for fresh description (avoids stale data from enqueue time)
|
|
187
188
|
let freshDesc;
|
|
188
189
|
try {
|
|
@@ -247,12 +248,30 @@ export async function runReviewCompletion(client, card, result, config, worktree
|
|
|
247
248
|
if (result.verdict === "approved") {
|
|
248
249
|
// Ensure branch is pushed (skip in local mode — no branch to push)
|
|
249
250
|
let prUrl = null;
|
|
251
|
+
let approvedBranch = branchName;
|
|
250
252
|
if (branchName) {
|
|
251
253
|
pushBranch(branchName, worktreePath);
|
|
254
|
+
// Graduate the branch from `agent-attempts/*` to `agent/*` so the
|
|
255
|
+
// approved PR opens on a clean ref. Renaming on origin is force-with-
|
|
256
|
+
// lease + delete-old; the old ref is the same SHA, so no work is lost.
|
|
257
|
+
const failedPrefix = config.worktree.failedBranchPrefix;
|
|
258
|
+
const approvedPrefix = config.worktree.approvedBranchPrefix;
|
|
259
|
+
if (failedPrefix &&
|
|
260
|
+
approvedPrefix &&
|
|
261
|
+
branchName.startsWith(failedPrefix)) {
|
|
262
|
+
const newRef = `${approvedPrefix}${branchName.slice(failedPrefix.length)}`;
|
|
263
|
+
try {
|
|
264
|
+
renameRemoteBranch(branchName, newRef, worktreePath);
|
|
265
|
+
approvedBranch = newRef;
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
log.warn(TAG, `Branch rename failed (continuing on ${branchName}): ${err instanceof Error ? err.message : err}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
252
271
|
// Create PR if configured
|
|
253
|
-
if (config.review.createPR) {
|
|
272
|
+
if (config.review.createPR && approvedBranch) {
|
|
254
273
|
const provider = detectGitProvider(worktreePath);
|
|
255
|
-
prUrl = createPullRequest(card,
|
|
274
|
+
prUrl = createPullRequest(card, approvedBranch, worktreePath, config, provider);
|
|
256
275
|
}
|
|
257
276
|
}
|
|
258
277
|
// Add "Ready to Merge" label
|
|
@@ -321,6 +340,24 @@ export async function runReviewCompletion(client, card, result, config, worktree
|
|
|
321
340
|
status: "completed",
|
|
322
341
|
...buildTokenPayload(sessionStats),
|
|
323
342
|
});
|
|
343
|
+
// Max-cycles rejection: the verdict still teaches "this approach kept
|
|
344
|
+
// failing review" — write the episode + back-fill before exiting.
|
|
345
|
+
if (workspaceId) {
|
|
346
|
+
const origId = await findLatestImplementEpisode(client, workspaceId, card.project_id, card.short_id);
|
|
347
|
+
const reviewId = await writeEpisode(client, {
|
|
348
|
+
kind: "review",
|
|
349
|
+
card,
|
|
350
|
+
workspaceId,
|
|
351
|
+
verdict: "rejected",
|
|
352
|
+
summary: `Reached max review cycles (${maxCycles}). ${result.summary}`,
|
|
353
|
+
cost: sessionStats?.cost ?? null,
|
|
354
|
+
agentSessionId: agentSessionId ?? null,
|
|
355
|
+
originalEpisodeId: origId,
|
|
356
|
+
});
|
|
357
|
+
if (origId) {
|
|
358
|
+
await backfillReviewVerdict(client, origId, "rejected", reviewId);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
324
361
|
if (branchName) {
|
|
325
362
|
cleanupWorktree(worktreePath, branchName);
|
|
326
363
|
}
|
|
@@ -382,12 +419,54 @@ export async function runReviewCompletion(client, card, result, config, worktree
|
|
|
382
419
|
}
|
|
383
420
|
// Move back to failColumn (To Do) for re-implementation
|
|
384
421
|
await moveCardToColumn(client, card, config.review.failColumn);
|
|
422
|
+
const failureSummary = `Review rejected (cycle ${currentCycle}/${maxCycles}): ${criticalFindings.length} critical, ${majorFindings.length} major, ${minorFindings.length} minor`;
|
|
423
|
+
const recoveryBranch = branchName ?? undefined;
|
|
424
|
+
const recoveryUrl = branchName
|
|
425
|
+
? getBranchWebUrl(branchName, worktreePath)
|
|
426
|
+
: null;
|
|
427
|
+
try {
|
|
428
|
+
await stateStore?.recordFailureSummary(card.id, {
|
|
429
|
+
summary: failureSummary,
|
|
430
|
+
reason: "review",
|
|
431
|
+
recoveryBranch,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
log.debug(TAG, `recordFailureSummary failed: ${err instanceof Error ? err.message : err}`);
|
|
436
|
+
}
|
|
437
|
+
if (recoveryBranch) {
|
|
438
|
+
log.info(TAG, `#${card.short_id} recovery branch ${recoveryBranch}${recoveryUrl ? ` (${recoveryUrl})` : ""}`);
|
|
439
|
+
}
|
|
385
440
|
await client.endAgentSession(card.id, {
|
|
386
|
-
status: "
|
|
441
|
+
status: "failed",
|
|
442
|
+
failureReason: "review",
|
|
443
|
+
failureSummary,
|
|
444
|
+
recoveryBranch,
|
|
387
445
|
...buildTokenPayload(sessionStats),
|
|
388
446
|
});
|
|
389
447
|
log.info(TAG, `#${card.short_id} rejected (cycle ${currentCycle}/${maxCycles}) — moved to "${config.review.failColumn}"`);
|
|
390
448
|
}
|
|
449
|
+
// Episode write + verdict back-fill (Phase 1.5). Runs for approved or
|
|
450
|
+
// rejected verdicts only — "error" verdicts return early above. Best-effort:
|
|
451
|
+
// failures are logged by writeEpisode/backfillReviewVerdict and never block
|
|
452
|
+
// worktree cleanup.
|
|
453
|
+
if (workspaceId &&
|
|
454
|
+
(result.verdict === "approved" || result.verdict === "rejected")) {
|
|
455
|
+
const originalEpisodeId = await findLatestImplementEpisode(client, workspaceId, card.project_id, card.short_id);
|
|
456
|
+
const reviewEpisodeId = await writeEpisode(client, {
|
|
457
|
+
kind: "review",
|
|
458
|
+
card,
|
|
459
|
+
workspaceId,
|
|
460
|
+
verdict: result.verdict,
|
|
461
|
+
summary: result.summary,
|
|
462
|
+
cost: sessionStats?.cost ?? null,
|
|
463
|
+
agentSessionId: agentSessionId ?? null,
|
|
464
|
+
originalEpisodeId,
|
|
465
|
+
});
|
|
466
|
+
if (originalEpisodeId) {
|
|
467
|
+
await backfillReviewVerdict(client, originalEpisodeId, result.verdict, reviewEpisodeId);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
391
470
|
// Cleanup worktree (skip in local mode — no worktree to clean)
|
|
392
471
|
if (branchName) {
|
|
393
472
|
cleanupWorktree(worktreePath, branchName);
|
package/dist/review-worker.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare class ReviewWorker {
|
|
|
7
7
|
private client;
|
|
8
8
|
private onDone;
|
|
9
9
|
private stateStore;
|
|
10
|
+
private workspaceId?;
|
|
10
11
|
id: number;
|
|
11
12
|
state: WorkerState;
|
|
12
13
|
cardId: string | null;
|
|
@@ -22,7 +23,8 @@ export declare class ReviewWorker {
|
|
|
22
23
|
private aborted;
|
|
23
24
|
private runId;
|
|
24
25
|
private lastRunLogPath;
|
|
25
|
-
|
|
26
|
+
private sessionId;
|
|
27
|
+
constructor(id: number, config: AgentConfig, client: HarmonyApiClient, _userEmail: string, onDone: (worker: ReviewWorker) => void, stateStore: StateStore, workspaceId?: string | undefined, _projectId?: string);
|
|
26
28
|
private startHeartbeat;
|
|
27
29
|
private stopHeartbeat;
|
|
28
30
|
private recordPhase;
|
package/dist/review-worker.js
CHANGED
|
@@ -23,6 +23,7 @@ export class ReviewWorker {
|
|
|
23
23
|
client;
|
|
24
24
|
onDone;
|
|
25
25
|
stateStore;
|
|
26
|
+
workspaceId;
|
|
26
27
|
id;
|
|
27
28
|
state = "idle";
|
|
28
29
|
cardId = null;
|
|
@@ -38,11 +39,13 @@ export class ReviewWorker {
|
|
|
38
39
|
aborted = false;
|
|
39
40
|
runId = null;
|
|
40
41
|
lastRunLogPath = null;
|
|
41
|
-
|
|
42
|
+
sessionId = null;
|
|
43
|
+
constructor(id, config, client, _userEmail, onDone, stateStore, workspaceId, _projectId) {
|
|
42
44
|
this.config = config;
|
|
43
45
|
this.client = client;
|
|
44
46
|
this.onDone = onDone;
|
|
45
47
|
this.stateStore = stateStore;
|
|
48
|
+
this.workspaceId = workspaceId;
|
|
46
49
|
this.id = id;
|
|
47
50
|
}
|
|
48
51
|
startHeartbeat() {
|
|
@@ -152,7 +155,7 @@ export class ReviewWorker {
|
|
|
152
155
|
log.info(this.tag, `Review branch: ${this.branchName}`);
|
|
153
156
|
}
|
|
154
157
|
// Start agent session and make it visible on the board
|
|
155
|
-
await this.client.startAgentSession(card.id, {
|
|
158
|
+
const { session: reviewSession } = await this.client.startAgentSession(card.id, {
|
|
156
159
|
agentIdentifier: agentIdentifier(this.id),
|
|
157
160
|
agentName: `${AGENT_NAME} (Review)`,
|
|
158
161
|
status: "working",
|
|
@@ -161,6 +164,12 @@ export class ReviewWorker {
|
|
|
161
164
|
: "Setting up review worktree",
|
|
162
165
|
progressPercent: 5,
|
|
163
166
|
});
|
|
167
|
+
this.sessionId =
|
|
168
|
+
reviewSession &&
|
|
169
|
+
typeof reviewSession === "object" &&
|
|
170
|
+
"id" in reviewSession
|
|
171
|
+
? (reviewSession.id ?? null)
|
|
172
|
+
: null;
|
|
164
173
|
// Fire label addition concurrently with sync worktree checkout
|
|
165
174
|
const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
|
|
166
175
|
if (!localMode) {
|
|
@@ -182,12 +191,30 @@ export class ReviewWorker {
|
|
|
182
191
|
if (!localMode) {
|
|
183
192
|
log.info(this.tag, `Starting dev server on port ${port}...`);
|
|
184
193
|
this.devServerProcess = spawnInGroup("bun", ["run", "dev", "--", "--port", String(port)], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
194
|
+
// Surface the dev-server warmup as `waiting` so the AgentTopBar
|
|
195
|
+
// doesn't sit on a stale `working` task while the server takes
|
|
196
|
+
// 10–20 seconds to boot.
|
|
197
|
+
await this.client.updateAgentProgress(card.id, {
|
|
198
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
199
|
+
agentName: `${AGENT_NAME} (Review)`,
|
|
200
|
+
status: "waiting",
|
|
201
|
+
currentTask: `Starting dev server on port ${port}…`,
|
|
202
|
+
progressPercent: 10,
|
|
203
|
+
});
|
|
185
204
|
// Invariant I2: review only proceeds with a proven-live dev server.
|
|
186
205
|
// waitForDevServer rejects on timeout / exit; probeDevServer verifies
|
|
187
206
|
// the port actually answers HTTP.
|
|
188
207
|
await waitForDevServer(this.devServerProcess, 30_000);
|
|
189
208
|
await probeDevServer(port);
|
|
190
209
|
log.info(this.tag, `Dev server ready on port ${port}`);
|
|
210
|
+
// Restore active status now that the server is live.
|
|
211
|
+
await this.client.updateAgentProgress(card.id, {
|
|
212
|
+
agentIdentifier: agentIdentifier(this.id),
|
|
213
|
+
agentName: `${AGENT_NAME} (Review)`,
|
|
214
|
+
status: "working",
|
|
215
|
+
currentTask: "Reviewing changes",
|
|
216
|
+
progressPercent: 15,
|
|
217
|
+
});
|
|
191
218
|
}
|
|
192
219
|
if (this.aborted)
|
|
193
220
|
return;
|
|
@@ -281,7 +308,7 @@ export class ReviewWorker {
|
|
|
281
308
|
progressPercent: 80,
|
|
282
309
|
});
|
|
283
310
|
// Run review completion pipeline
|
|
284
|
-
await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath);
|
|
311
|
+
await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId, this.stateStore);
|
|
285
312
|
}
|
|
286
313
|
catch (err) {
|
|
287
314
|
this.state = "error";
|
package/dist/state-store.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ export interface RunRecord {
|
|
|
18
18
|
costCents: number;
|
|
19
19
|
errorMessage?: string;
|
|
20
20
|
}
|
|
21
|
+
export interface FailureSummaryRecord {
|
|
22
|
+
summary: string;
|
|
23
|
+
ts: number;
|
|
24
|
+
reason?: "verification" | "review" | "daemon_restart" | "budget" | "other";
|
|
25
|
+
recoveryBranch?: string;
|
|
26
|
+
}
|
|
21
27
|
export interface CardRecord {
|
|
22
28
|
cardId: string;
|
|
23
29
|
attempts: number;
|
|
@@ -26,6 +32,7 @@ export interface CardRecord {
|
|
|
26
32
|
dlqReason?: string;
|
|
27
33
|
lastAttemptAt: number | null;
|
|
28
34
|
lastOutcome: "success" | "failure" | null;
|
|
35
|
+
failureHistory?: FailureSummaryRecord[];
|
|
29
36
|
}
|
|
30
37
|
export declare function newRunId(): string;
|
|
31
38
|
export declare function defaultStatePath(): string;
|
|
@@ -63,6 +70,15 @@ export declare class StateStore {
|
|
|
63
70
|
getCard(cardId: string): CardRecord | null;
|
|
64
71
|
incrementAttempt(cardId: string): Promise<number>;
|
|
65
72
|
recordOutcome(cardId: string, outcome: "success" | "failure"): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Push a failure summary onto the card's bounded history (most-recent first,
|
|
75
|
+
* capped at 5). Read back by DLQ comment formatting and the Agent History
|
|
76
|
+
* UI section to give users a post-mortem trail across attempts.
|
|
77
|
+
*/
|
|
78
|
+
recordFailureSummary(cardId: string, entry: Omit<FailureSummaryRecord, "ts"> & {
|
|
79
|
+
ts?: number;
|
|
80
|
+
}): Promise<void>;
|
|
81
|
+
getRecentFailures(cardId: string, limit?: number): FailureSummaryRecord[];
|
|
66
82
|
addCost(cardId: string, cents: number): Promise<void>;
|
|
67
83
|
getDailyCostCents(date?: string): number;
|
|
68
84
|
markDlq(cardId: string, reason: string): Promise<void>;
|
package/dist/state-store.js
CHANGED
|
@@ -170,6 +170,29 @@ export class StateStore {
|
|
|
170
170
|
rec.attempts = 0;
|
|
171
171
|
await this.persist();
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Push a failure summary onto the card's bounded history (most-recent first,
|
|
175
|
+
* capped at 5). Read back by DLQ comment formatting and the Agent History
|
|
176
|
+
* UI section to give users a post-mortem trail across attempts.
|
|
177
|
+
*/
|
|
178
|
+
async recordFailureSummary(cardId, entry) {
|
|
179
|
+
const rec = this.ensureCard(cardId);
|
|
180
|
+
const next = {
|
|
181
|
+
summary: entry.summary.slice(0, 500),
|
|
182
|
+
ts: entry.ts ?? Date.now(),
|
|
183
|
+
reason: entry.reason,
|
|
184
|
+
recoveryBranch: entry.recoveryBranch,
|
|
185
|
+
};
|
|
186
|
+
const existing = rec.failureHistory ?? [];
|
|
187
|
+
rec.failureHistory = [next, ...existing].slice(0, 5);
|
|
188
|
+
await this.persist();
|
|
189
|
+
}
|
|
190
|
+
getRecentFailures(cardId, limit = 3) {
|
|
191
|
+
const rec = this.getCard(cardId);
|
|
192
|
+
if (!rec?.failureHistory)
|
|
193
|
+
return [];
|
|
194
|
+
return rec.failureHistory.slice(0, limit);
|
|
195
|
+
}
|
|
173
196
|
async addCost(cardId, cents) {
|
|
174
197
|
if (cents <= 0)
|
|
175
198
|
return;
|