@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/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 { AgentConfig, WorkMode } from "./types.js";
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;
@@ -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: "paused",
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 (paused)");
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 { createPullRequest, detectGitProvider, pushBranch } from "./git-pr.js";
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, branchName, worktreePath, config, provider);
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: "paused",
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);
@@ -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
- constructor(id: number, config: AgentConfig, client: HarmonyApiClient, _userEmail: string, onDone: (worker: ReviewWorker) => void, stateStore: StateStore);
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;
@@ -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
- constructor(id, config, client, _userEmail, onDone, stateStore) {
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";
@@ -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>;
@@ -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;