@gethmy/agent 1.5.0 → 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 +2 -1
- package/dist/completion.js +71 -11
- 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 +38 -4
- package/dist/recovery.js +8 -2
- package/dist/review-completion.d.ts +2 -1
- package/dist/review-completion.js +44 -5
- package/dist/review-worker.js +19 -1
- package/dist/state-store.d.ts +16 -0
- package/dist/state-store.js +23 -0
- package/dist/types.d.ts +6 -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/budget.d.ts
CHANGED
|
@@ -38,8 +38,10 @@ export declare class BudgetGuard {
|
|
|
38
38
|
*/
|
|
39
39
|
isTerminal(reason: GuardReason): boolean;
|
|
40
40
|
/**
|
|
41
|
-
* Apply the DLQ label to a card
|
|
42
|
-
*
|
|
41
|
+
* Apply the DLQ label to a card, persist the reason, and append a
|
|
42
|
+
* post-mortem block to the card description listing the last 3 failure
|
|
43
|
+
* summaries. Safe to call repeatedly — labels are idempotent and the
|
|
44
|
+
* description block is delimited so reruns replace rather than stack.
|
|
43
45
|
*/
|
|
44
46
|
markDlq(client: HarmonyApiClient, card: Card, reason: GuardReason, detail: string): Promise<void>;
|
|
45
47
|
}
|
package/dist/budget.js
CHANGED
|
@@ -71,8 +71,10 @@ export class BudgetGuard {
|
|
|
71
71
|
reason === "card_cost_cap");
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
|
-
* Apply the DLQ label to a card
|
|
75
|
-
*
|
|
74
|
+
* Apply the DLQ label to a card, persist the reason, and append a
|
|
75
|
+
* post-mortem block to the card description listing the last 3 failure
|
|
76
|
+
* summaries. Safe to call repeatedly — labels are idempotent and the
|
|
77
|
+
* description block is delimited so reruns replace rather than stack.
|
|
76
78
|
*/
|
|
77
79
|
async markDlq(client, card, reason, detail) {
|
|
78
80
|
await this.store.markDlq(card.id, `${reason}: ${detail}`);
|
|
@@ -86,9 +88,46 @@ export class BudgetGuard {
|
|
|
86
88
|
catch (err) {
|
|
87
89
|
log.warn(TAG, `failed to add dlq label to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
|
|
88
90
|
}
|
|
91
|
+
try {
|
|
92
|
+
const recent = this.store.getRecentFailures(card.id, 3);
|
|
93
|
+
const block = buildDlqDescriptionBlock(reason, detail, recent);
|
|
94
|
+
const existing = card.description ?? "";
|
|
95
|
+
const stripped = stripDlqBlock(existing);
|
|
96
|
+
await client.updateCard(card.id, {
|
|
97
|
+
description: `${stripped}${stripped ? "\n\n" : ""}${block}`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
log.warn(TAG, `failed to post DLQ summary to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
|
|
102
|
+
}
|
|
89
103
|
log.warn(TAG, `#${card.short_id} DLQ'd — ${reason}: ${detail}`);
|
|
90
104
|
}
|
|
91
105
|
}
|
|
106
|
+
const DLQ_MARKER = "---\n**Agent DLQ**";
|
|
107
|
+
function buildDlqDescriptionBlock(reason, detail, failures) {
|
|
108
|
+
const lines = [DLQ_MARKER, `Cap hit: ${reason} — ${detail}`];
|
|
109
|
+
if (failures.length > 0) {
|
|
110
|
+
lines.push("", "Recent failures:");
|
|
111
|
+
for (const f of failures) {
|
|
112
|
+
const when = new Date(f.ts).toISOString().replace("T", " ").slice(0, 16);
|
|
113
|
+
const tag = f.reason ? ` [${f.reason}]` : "";
|
|
114
|
+
const branch = f.recoveryBranch
|
|
115
|
+
? `\n recover: \`git fetch && git checkout ${f.recoveryBranch}\``
|
|
116
|
+
: "";
|
|
117
|
+
lines.push(`- ${when} UTC${tag} — ${f.summary}${branch}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
lines.push("", "_No prior failure summaries recorded._");
|
|
122
|
+
}
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
function stripDlqBlock(description) {
|
|
126
|
+
const idx = description.indexOf(DLQ_MARKER);
|
|
127
|
+
if (idx < 0)
|
|
128
|
+
return description.trimEnd();
|
|
129
|
+
return description.slice(0, idx).trimEnd();
|
|
130
|
+
}
|
|
92
131
|
function formatCents(cents) {
|
|
93
132
|
return `$${(cents / 100).toFixed(2)}`;
|
|
94
133
|
}
|
package/dist/completion.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
2
|
import type { Card } from "@harmony/shared";
|
|
3
|
+
import type { StateStore } from "./state-store.js";
|
|
3
4
|
import type { CostUpdate } from "./stream-parser.js";
|
|
4
5
|
import { type AgentConfig } from "./types.js";
|
|
5
6
|
export interface SessionStats {
|
|
@@ -28,4 +29,4 @@ export declare function buildTokenPayload(stats?: SessionStats | null): {
|
|
|
28
29
|
/**
|
|
29
30
|
* Post-work pipeline: push branch, create PR, move card, post summary.
|
|
30
31
|
*/
|
|
31
|
-
export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId?: number, sessionStats?: SessionStats, workspaceId?: string, agentSessionId?: string | null): Promise<void>;
|
|
32
|
+
export declare function runCompletion(client: HarmonyApiClient, card: Card, branchName: string, worktreePath: string, config: AgentConfig, workerId?: number, sessionStats?: SessionStats, workspaceId?: string, agentSessionId?: string | null, stateStore?: StateStore): Promise<void>;
|
package/dist/completion.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { moveCardToColumn } from "./board-helpers.js";
|
|
3
3
|
import { writeEpisode } from "./episode-writer.js";
|
|
4
|
-
import { createPullRequest, detectGitProvider, pushBranch } from "./git-pr.js";
|
|
4
|
+
import { createPullRequest, detectGitProvider, getBranchWebUrl, pushBranch, } from "./git-pr.js";
|
|
5
5
|
import { log } from "./log.js";
|
|
6
6
|
import { AGENT_NAME, agentIdentifier } from "./types.js";
|
|
7
7
|
import { attemptAutoFix, reportFindings, runVerification, } from "./verification.js";
|
|
@@ -29,7 +29,7 @@ export function buildTokenPayload(stats) {
|
|
|
29
29
|
/**
|
|
30
30
|
* Post-work pipeline: push branch, create PR, move card, post summary.
|
|
31
31
|
*/
|
|
32
|
-
export async function runCompletion(client, card, branchName, worktreePath, config, workerId = 0, sessionStats, workspaceId, agentSessionId) {
|
|
32
|
+
export async function runCompletion(client, card, branchName, worktreePath, config, workerId = 0, sessionStats, workspaceId, agentSessionId, stateStore) {
|
|
33
33
|
// Hoisted so the episode write hook can read final verification state.
|
|
34
34
|
let verificationResult = {
|
|
35
35
|
passed: true,
|
|
@@ -49,7 +49,22 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
|
|
|
49
49
|
cleanupWorktree(worktreePath, branchName);
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
|
-
// 1.
|
|
52
|
+
// 1. Push branch FIRST so commits are durable on origin regardless of
|
|
53
|
+
// verification outcome. A failed verify (below) then preserves the work
|
|
54
|
+
// under `agent-attempts/*` for `failedAttemptRetentionDays`. Without this
|
|
55
|
+
// ordering, verify failures used to orphan commits in a deleted worktree —
|
|
56
|
+
// recoverable only via `git reflog`.
|
|
57
|
+
log.info(TAG, `Pushing branch ${branchName} (pre-verify)...`);
|
|
58
|
+
try {
|
|
59
|
+
pushBranch(branchName, worktreePath);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// Push failure shouldn't prevent verification from running, but the
|
|
63
|
+
// safety guarantee is gone — surface it loudly so the operator notices.
|
|
64
|
+
log.error(TAG, `pre-verify push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
|
|
65
|
+
}
|
|
66
|
+
const recoveryUrl = getBranchWebUrl(branchName, worktreePath);
|
|
67
|
+
// 2. Verification gate
|
|
53
68
|
if (config.verification.enabled) {
|
|
54
69
|
await client.updateAgentProgress(card.id, {
|
|
55
70
|
agentIdentifier: agentIdentifier(workerId),
|
|
@@ -59,6 +74,7 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
|
|
|
59
74
|
progressPercent: 80,
|
|
60
75
|
});
|
|
61
76
|
let result = await runVerification(worktreePath, config, workerId);
|
|
77
|
+
let autoFixAttempts = 0;
|
|
62
78
|
if (!result.passed && config.verification.autoFix) {
|
|
63
79
|
for (let attempt = 0; attempt < config.verification.maxFixAttempts; attempt++) {
|
|
64
80
|
log.info(TAG, `Auto-fix attempt ${attempt + 1}/${config.verification.maxFixAttempts}`);
|
|
@@ -72,8 +88,16 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
|
|
|
72
88
|
const allErrors = [...result.buildErrors, ...result.lintWarnings];
|
|
73
89
|
await attemptAutoFix(worktreePath, config, allErrors);
|
|
74
90
|
result = await runVerification(worktreePath, config, workerId);
|
|
91
|
+
autoFixAttempts = attempt + 1;
|
|
75
92
|
if (result.passed) {
|
|
76
93
|
log.info(TAG, `Auto-fix succeeded on attempt ${attempt + 1}`);
|
|
94
|
+
// Push again so the auto-fix commit is also durable on origin.
|
|
95
|
+
try {
|
|
96
|
+
pushBranch(branchName, worktreePath);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
log.warn(TAG, `post-fix push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
|
|
100
|
+
}
|
|
77
101
|
break;
|
|
78
102
|
}
|
|
79
103
|
}
|
|
@@ -81,23 +105,44 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
|
|
|
81
105
|
verificationResult = result;
|
|
82
106
|
if (!result.passed) {
|
|
83
107
|
log.warn(TAG, `Verification failed for #${card.short_id} — reporting findings`);
|
|
84
|
-
|
|
108
|
+
// Push the latest tip (including any auto-fix attempts) so the
|
|
109
|
+
// failed branch on origin reflects what verify saw.
|
|
110
|
+
try {
|
|
111
|
+
pushBranch(branchName, worktreePath);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
log.warn(TAG, `post-fail push failed for ${branchName}: ${err instanceof Error ? err.message : err}`);
|
|
115
|
+
}
|
|
116
|
+
const failureSummary = buildVerificationFailureSummary(result, autoFixAttempts);
|
|
117
|
+
try {
|
|
118
|
+
await stateStore?.recordFailureSummary(card.id, {
|
|
119
|
+
summary: failureSummary,
|
|
120
|
+
reason: "verification",
|
|
121
|
+
recoveryBranch: branchName,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
log.debug(TAG, `recordFailureSummary failed: ${err instanceof Error ? err.message : err}`);
|
|
126
|
+
}
|
|
127
|
+
await reportFindings(client, card.id, result, {
|
|
128
|
+
branchName,
|
|
129
|
+
branchUrl: recoveryUrl,
|
|
130
|
+
});
|
|
85
131
|
await moveCardToColumn(client, card, config.verification.failColumn);
|
|
86
132
|
await client.endAgentSession(card.id, {
|
|
87
|
-
status: "
|
|
133
|
+
status: "failed",
|
|
134
|
+
failureReason: "verification",
|
|
135
|
+
failureSummary,
|
|
136
|
+
recoveryBranch: branchName,
|
|
88
137
|
...buildTokenPayload(sessionStats),
|
|
89
138
|
});
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// hit when verification fails.
|
|
139
|
+
// Local-only cleanup. The remote ref under `agent-attempts/*` stays
|
|
140
|
+
// up; the GC sweep (worktree-gc.ts) prunes it after retention.
|
|
93
141
|
cleanupWorktree(worktreePath, branchName);
|
|
94
142
|
return;
|
|
95
143
|
}
|
|
96
144
|
log.info(TAG, `Verification passed for #${card.short_id}`);
|
|
97
145
|
}
|
|
98
|
-
// 2. Push branch (force-push on rework if remote branch already exists)
|
|
99
|
-
log.info(TAG, `Pushing branch ${branchName}...`);
|
|
100
|
-
pushBranch(branchName, worktreePath);
|
|
101
146
|
// 3. Create PR
|
|
102
147
|
let prUrl = null;
|
|
103
148
|
if (config.completion.createPR) {
|
|
@@ -144,6 +189,21 @@ export async function runCompletion(client, card, branchName, worktreePath, conf
|
|
|
144
189
|
cleanupWorktree(worktreePath, branchName);
|
|
145
190
|
log.info(TAG, `Completion done for #${card.short_id}${prUrl ? ` — PR: ${prUrl}` : ""}`);
|
|
146
191
|
}
|
|
192
|
+
function buildVerificationFailureSummary(result, autoFixAttempts) {
|
|
193
|
+
const counts = [];
|
|
194
|
+
if (result.buildErrors.length > 0) {
|
|
195
|
+
counts.push(`${result.buildErrors.length} build error(s)`);
|
|
196
|
+
}
|
|
197
|
+
if (result.lintWarnings.length > 0) {
|
|
198
|
+
counts.push(`${result.lintWarnings.length} lint issue(s)`);
|
|
199
|
+
}
|
|
200
|
+
if (result.reviewFindings.length > 0) {
|
|
201
|
+
counts.push(`${result.reviewFindings.length} review finding(s)`);
|
|
202
|
+
}
|
|
203
|
+
const head = counts.length > 0 ? counts.join(", ") : "verification failed";
|
|
204
|
+
const tail = autoFixAttempts > 0 ? ` after ${autoFixAttempts} auto-fix attempt(s)` : "";
|
|
205
|
+
return `${head}${tail}`;
|
|
206
|
+
}
|
|
147
207
|
function checkHasCommits(worktreePath, baseBranch) {
|
|
148
208
|
try {
|
|
149
209
|
const count = execFileSync("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8" }).trim();
|
package/dist/git-pr.d.ts
CHANGED
|
@@ -19,6 +19,19 @@ export declare function checkPrMergeStatus(prUrl: string, cwd: string, provider:
|
|
|
19
19
|
export declare function extractPrUrl(description: string | null): string | null;
|
|
20
20
|
export declare function remoteBranchExists(branchName: string, cwd: string): boolean;
|
|
21
21
|
export declare function pushBranch(branchName: string, cwd: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Push the current branch's tip to `newRef` on origin and delete `oldRef`.
|
|
24
|
+
* Used when an approved attempt graduates from `agent-attempts/*` to
|
|
25
|
+
* `agent/*` — keeps the commits durable across the rename and avoids any
|
|
26
|
+
* window where the work is unreachable on origin.
|
|
27
|
+
*/
|
|
28
|
+
export declare function renameRemoteBranch(oldRef: string, newRef: string, cwd: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* Best-effort public branch URL for the recovery button on a failed session.
|
|
31
|
+
* Returns null when we can't infer a tree URL — the daemon falls back to a
|
|
32
|
+
* plain `git fetch && git checkout <ref>` instruction in that case.
|
|
33
|
+
*/
|
|
34
|
+
export declare function getBranchWebUrl(branchName: string, cwd: string): string | null;
|
|
22
35
|
export declare function buildPrBody(card: Card, commitLog: string): string;
|
|
23
36
|
export declare function createPullRequest(card: Card, branchName: string, worktreePath: string, config: AgentConfig, provider: GitProvider): string | null;
|
|
24
37
|
export declare function findExistingPr(branchName: string, worktreePath: string, provider: GitProvider): string | null;
|
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 {
|
|
@@ -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
|
}
|
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, workspaceId?: string, agentSessionId?: 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>;
|
|
@@ -2,7 +2,7 @@ import { readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
|
|
3
3
|
import { buildTokenPayload } from "./completion.js";
|
|
4
4
|
import { backfillReviewVerdict, findLatestImplementEpisode, writeEpisode, } from "./episode-writer.js";
|
|
5
|
-
import { createPullRequest, detectGitProvider, pushBranch } from "./git-pr.js";
|
|
5
|
+
import { createPullRequest, detectGitProvider, getBranchWebUrl, pushBranch, renameRemoteBranch, } from "./git-pr.js";
|
|
6
6
|
import { log } from "./log.js";
|
|
7
7
|
import { NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR, } from "./types.js";
|
|
8
8
|
import { cleanupWorktree } from "./worktree.js";
|
|
@@ -183,7 +183,7 @@ function stripReviewSummary(description) {
|
|
|
183
183
|
* Handles approved/rejected verdicts, creates subtasks for findings,
|
|
184
184
|
* and moves the card to the appropriate column.
|
|
185
185
|
*/
|
|
186
|
-
export async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath, workspaceId, agentSessionId) {
|
|
186
|
+
export async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath, workspaceId, agentSessionId, stateStore) {
|
|
187
187
|
// Re-fetch card for fresh description (avoids stale data from enqueue time)
|
|
188
188
|
let freshDesc;
|
|
189
189
|
try {
|
|
@@ -248,12 +248,30 @@ export async function runReviewCompletion(client, card, result, config, worktree
|
|
|
248
248
|
if (result.verdict === "approved") {
|
|
249
249
|
// Ensure branch is pushed (skip in local mode — no branch to push)
|
|
250
250
|
let prUrl = null;
|
|
251
|
+
let approvedBranch = branchName;
|
|
251
252
|
if (branchName) {
|
|
252
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
|
+
}
|
|
253
271
|
// Create PR if configured
|
|
254
|
-
if (config.review.createPR) {
|
|
272
|
+
if (config.review.createPR && approvedBranch) {
|
|
255
273
|
const provider = detectGitProvider(worktreePath);
|
|
256
|
-
prUrl = createPullRequest(card,
|
|
274
|
+
prUrl = createPullRequest(card, approvedBranch, worktreePath, config, provider);
|
|
257
275
|
}
|
|
258
276
|
}
|
|
259
277
|
// Add "Ready to Merge" label
|
|
@@ -401,8 +419,29 @@ export async function runReviewCompletion(client, card, result, config, worktree
|
|
|
401
419
|
}
|
|
402
420
|
// Move back to failColumn (To Do) for re-implementation
|
|
403
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
|
+
}
|
|
404
440
|
await client.endAgentSession(card.id, {
|
|
405
|
-
status: "
|
|
441
|
+
status: "failed",
|
|
442
|
+
failureReason: "review",
|
|
443
|
+
failureSummary,
|
|
444
|
+
recoveryBranch,
|
|
406
445
|
...buildTokenPayload(sessionStats),
|
|
407
446
|
});
|
|
408
447
|
log.info(TAG, `#${card.short_id} rejected (cycle ${currentCycle}/${maxCycles}) — moved to "${config.review.failColumn}"`);
|
package/dist/review-worker.js
CHANGED
|
@@ -191,12 +191,30 @@ export class ReviewWorker {
|
|
|
191
191
|
if (!localMode) {
|
|
192
192
|
log.info(this.tag, `Starting dev server on port ${port}...`);
|
|
193
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
|
+
});
|
|
194
204
|
// Invariant I2: review only proceeds with a proven-live dev server.
|
|
195
205
|
// waitForDevServer rejects on timeout / exit; probeDevServer verifies
|
|
196
206
|
// the port actually answers HTTP.
|
|
197
207
|
await waitForDevServer(this.devServerProcess, 30_000);
|
|
198
208
|
await probeDevServer(port);
|
|
199
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
|
+
});
|
|
200
218
|
}
|
|
201
219
|
if (this.aborted)
|
|
202
220
|
return;
|
|
@@ -290,7 +308,7 @@ export class ReviewWorker {
|
|
|
290
308
|
progressPercent: 80,
|
|
291
309
|
});
|
|
292
310
|
// Run review completion pipeline
|
|
293
|
-
await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId);
|
|
311
|
+
await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId, this.stateStore);
|
|
294
312
|
}
|
|
295
313
|
catch (err) {
|
|
296
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;
|
package/dist/types.d.ts
CHANGED
|
@@ -20,6 +20,12 @@ export interface AgentConfig {
|
|
|
20
20
|
worktree: {
|
|
21
21
|
basePath: string;
|
|
22
22
|
baseBranch: string;
|
|
23
|
+
/** Remote-branch prefix while an attempt is still in-flight or failed. */
|
|
24
|
+
failedBranchPrefix: string;
|
|
25
|
+
/** Remote-branch prefix after a successful run reaches Review. */
|
|
26
|
+
approvedBranchPrefix: string;
|
|
27
|
+
/** Days to keep failed-attempt branches on origin before GC removes them. */
|
|
28
|
+
failedAttemptRetentionDays: number;
|
|
23
29
|
};
|
|
24
30
|
verification: {
|
|
25
31
|
enabled: boolean;
|
package/dist/types.js
CHANGED
|
@@ -18,6 +18,9 @@ export const DEFAULT_AGENT_CONFIG = {
|
|
|
18
18
|
worktree: {
|
|
19
19
|
basePath: ".harmony-worktrees",
|
|
20
20
|
baseBranch: "main",
|
|
21
|
+
failedBranchPrefix: "agent-attempts/",
|
|
22
|
+
approvedBranchPrefix: "agent/",
|
|
23
|
+
failedAttemptRetentionDays: 7,
|
|
21
24
|
},
|
|
22
25
|
verification: {
|
|
23
26
|
enabled: true,
|
package/dist/verification.d.ts
CHANGED
|
@@ -12,7 +12,13 @@ export declare function runBuild(worktreePath: string, timeout: number): string[
|
|
|
12
12
|
export declare function runLint(worktreePath: string, timeout: number): string[];
|
|
13
13
|
export declare function runDeepReview(worktreePath: string, config: AgentConfig, workerId: number): Promise<string[]>;
|
|
14
14
|
export declare function attemptAutoFix(worktreePath: string, config: AgentConfig, errors: string[]): void;
|
|
15
|
-
export
|
|
15
|
+
export interface RecoveryInfo {
|
|
16
|
+
/** Remote ref where the failed attempt was pushed. */
|
|
17
|
+
branchName: string;
|
|
18
|
+
/** Public URL of the branch (GitHub/GitLab/Bitbucket tree view), if known. */
|
|
19
|
+
branchUrl: string | null;
|
|
20
|
+
}
|
|
21
|
+
export declare function reportFindings(client: HarmonyApiClient, cardId: string, result: VerificationResult, recovery?: RecoveryInfo | null): Promise<void>;
|
|
16
22
|
export declare class DevServerReadinessError extends Error {
|
|
17
23
|
constructor(message: string);
|
|
18
24
|
}
|
package/dist/verification.js
CHANGED
|
@@ -160,8 +160,13 @@ export function attemptAutoFix(worktreePath, config, errors) {
|
|
|
160
160
|
stdio: "pipe",
|
|
161
161
|
});
|
|
162
162
|
}
|
|
163
|
-
export async function reportFindings(client, cardId, result) {
|
|
163
|
+
export async function reportFindings(client, cardId, result, recovery) {
|
|
164
164
|
const items = [];
|
|
165
|
+
if (recovery) {
|
|
166
|
+
const cmd = `git fetch && git checkout ${recovery.branchName}`;
|
|
167
|
+
const url = recovery.branchUrl ? ` (${recovery.branchUrl})` : "";
|
|
168
|
+
items.push(`Recovery: \`${cmd}\`${url}`);
|
|
169
|
+
}
|
|
165
170
|
for (const err of result.buildErrors) {
|
|
166
171
|
items.push(`Build: ${err}`);
|
|
167
172
|
}
|
package/dist/worker.js
CHANGED
|
@@ -102,7 +102,7 @@ export class Worker {
|
|
|
102
102
|
try {
|
|
103
103
|
// --- PREPARING ---
|
|
104
104
|
this.state = "preparing";
|
|
105
|
-
this.branchName = makeBranchName(card.short_id, card.title);
|
|
105
|
+
this.branchName = makeBranchName(card.short_id, card.title, this.config.worktree.failedBranchPrefix);
|
|
106
106
|
log.info(this.tag, `Preparing #${card.short_id} "${card.title}"`);
|
|
107
107
|
// Per-card attempt counter resets on success; DLQ triggers off it.
|
|
108
108
|
await this.stateStore.incrementAttempt(card.id);
|
|
@@ -195,7 +195,7 @@ export class Worker {
|
|
|
195
195
|
});
|
|
196
196
|
this.state = "completing";
|
|
197
197
|
await this.recordPhase("completing");
|
|
198
|
-
await runCompletion(this.client, card, this.branchName, this.worktreePath, this.config, this.id, this.lastSessionStats, this.workspaceId, this.sessionId);
|
|
198
|
+
await runCompletion(this.client, card, this.branchName, this.worktreePath, this.config, this.id, this.lastSessionStats, this.workspaceId, this.sessionId, this.stateStore);
|
|
199
199
|
}
|
|
200
200
|
catch (err) {
|
|
201
201
|
this.state = "error";
|
package/dist/worktree-gc.d.ts
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import type { StateStore } from "./state-store.js";
|
|
2
|
+
export interface RemoteBranchGcOptions {
|
|
3
|
+
/** Prefix to scan (e.g. `agent-attempts/`). */
|
|
4
|
+
prefix: string;
|
|
5
|
+
/** Retention in days. Branches older than this are removed. */
|
|
6
|
+
retentionDays: number;
|
|
7
|
+
/** Optional sync clock, for deterministic tests. */
|
|
8
|
+
now?: () => number;
|
|
9
|
+
}
|
|
10
|
+
export interface RemoteBranchGcResult {
|
|
11
|
+
scanned: number;
|
|
12
|
+
removed: string[];
|
|
13
|
+
skipped: string[];
|
|
14
|
+
errors: Array<{
|
|
15
|
+
ref: string;
|
|
16
|
+
error: string;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
2
19
|
export interface GcResult {
|
|
3
20
|
checked: number;
|
|
4
21
|
removed: string[];
|
|
@@ -27,12 +44,23 @@ export interface GcOptions {
|
|
|
27
44
|
* Returns a summary; callers decide whether to log at info or warn.
|
|
28
45
|
*/
|
|
29
46
|
export declare function runWorktreeGc(basePath: string, store: StateStore, opts?: GcOptions): GcResult;
|
|
47
|
+
/**
|
|
48
|
+
* Sweep stale failed-attempt branches off the remote.
|
|
49
|
+
*
|
|
50
|
+
* Lists `origin/<prefix>*`, asks git for each ref's committer timestamp via
|
|
51
|
+
* `for-each-ref`, and deletes anything older than `retentionDays`. Runs on
|
|
52
|
+
* the same GC tick that handles worktree directories — protecting unpushed
|
|
53
|
+
* commits is the job of the daemon (always push before verify); this sweep
|
|
54
|
+
* just keeps the namespace tidy.
|
|
55
|
+
*/
|
|
56
|
+
export declare function pruneFailedRemoteBranches(opts: RemoteBranchGcOptions): RemoteBranchGcResult;
|
|
30
57
|
export declare class WorktreeGc {
|
|
31
58
|
private basePath;
|
|
32
59
|
private store;
|
|
33
60
|
private intervalMs;
|
|
61
|
+
private remoteOpts?;
|
|
34
62
|
private timer;
|
|
35
|
-
constructor(basePath: string, store: StateStore, intervalMs: number);
|
|
63
|
+
constructor(basePath: string, store: StateStore, intervalMs: number, remoteOpts?: RemoteBranchGcOptions | undefined);
|
|
36
64
|
start(): void;
|
|
37
65
|
stop(): void;
|
|
38
66
|
private tick;
|
package/dist/worktree-gc.js
CHANGED
|
@@ -96,15 +96,114 @@ export function runWorktreeGc(basePath, store, opts = {}) {
|
|
|
96
96
|
}
|
|
97
97
|
return result;
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Sweep stale failed-attempt branches off the remote.
|
|
101
|
+
*
|
|
102
|
+
* Lists `origin/<prefix>*`, asks git for each ref's committer timestamp via
|
|
103
|
+
* `for-each-ref`, and deletes anything older than `retentionDays`. Runs on
|
|
104
|
+
* the same GC tick that handles worktree directories — protecting unpushed
|
|
105
|
+
* commits is the job of the daemon (always push before verify); this sweep
|
|
106
|
+
* just keeps the namespace tidy.
|
|
107
|
+
*/
|
|
108
|
+
export function pruneFailedRemoteBranches(opts) {
|
|
109
|
+
const result = {
|
|
110
|
+
scanned: 0,
|
|
111
|
+
removed: [],
|
|
112
|
+
skipped: [],
|
|
113
|
+
errors: [],
|
|
114
|
+
};
|
|
115
|
+
if (!opts.prefix)
|
|
116
|
+
return result;
|
|
117
|
+
// 0 or negative retention is opt-out — caller wants nothing pruned.
|
|
118
|
+
if (!Number.isFinite(opts.retentionDays) || opts.retentionDays <= 0) {
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
const repoRoot = getRepoRoot();
|
|
122
|
+
if (!repoRoot) {
|
|
123
|
+
result.errors.push({ ref: "<repo-root>", error: "not a git repo" });
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
// Refresh the remote refs we know about. Pruned remote branches drop from
|
|
127
|
+
// local tracking, so we never try to delete refs the server already lost.
|
|
128
|
+
try {
|
|
129
|
+
execFileSync("git", ["fetch", "--prune", "origin"], {
|
|
130
|
+
cwd: repoRoot,
|
|
131
|
+
stdio: "pipe",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
result.errors.push({
|
|
136
|
+
ref: "fetch",
|
|
137
|
+
error: err instanceof Error ? err.message : String(err),
|
|
138
|
+
});
|
|
139
|
+
// Continue — for-each-ref may still find usable cached refs.
|
|
140
|
+
}
|
|
141
|
+
const refPattern = `refs/remotes/origin/${opts.prefix}*`;
|
|
142
|
+
let listing = "";
|
|
143
|
+
try {
|
|
144
|
+
listing = execFileSync("git", [
|
|
145
|
+
"for-each-ref",
|
|
146
|
+
"--format=%(refname:strip=3) %(committerdate:unix)",
|
|
147
|
+
refPattern,
|
|
148
|
+
], { cwd: repoRoot, encoding: "utf-8" });
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
result.errors.push({
|
|
152
|
+
ref: refPattern,
|
|
153
|
+
error: err instanceof Error ? err.message : String(err),
|
|
154
|
+
});
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
const cutoffSecs = (opts.now ?? Date.now)() / 1000 - opts.retentionDays * 24 * 60 * 60;
|
|
158
|
+
for (const line of listing.split("\n")) {
|
|
159
|
+
const trimmed = line.trim();
|
|
160
|
+
if (!trimmed)
|
|
161
|
+
continue;
|
|
162
|
+
const sp = trimmed.lastIndexOf(" ");
|
|
163
|
+
if (sp < 0)
|
|
164
|
+
continue;
|
|
165
|
+
const ref = trimmed.slice(0, sp);
|
|
166
|
+
const ts = Number(trimmed.slice(sp + 1));
|
|
167
|
+
result.scanned++;
|
|
168
|
+
if (!Number.isFinite(ts) || ts >= cutoffSecs) {
|
|
169
|
+
result.skipped.push(ref);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
execFileSync("git", ["push", "origin", `:refs/heads/${ref}`], {
|
|
174
|
+
cwd: repoRoot,
|
|
175
|
+
stdio: "pipe",
|
|
176
|
+
});
|
|
177
|
+
result.removed.push(ref);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
result.errors.push({
|
|
181
|
+
ref,
|
|
182
|
+
error: err instanceof Error ? err.message : String(err),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (result.removed.length > 0) {
|
|
187
|
+
log.info(TAG, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
|
|
188
|
+
}
|
|
189
|
+
if (result.errors.length > 0) {
|
|
190
|
+
log.warn(TAG, `Remote branch GC had ${result.errors.length} error(s): ${result.errors
|
|
191
|
+
.map((e) => `${e.ref}: ${e.error}`)
|
|
192
|
+
.join("; ")}`);
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
99
196
|
export class WorktreeGc {
|
|
100
197
|
basePath;
|
|
101
198
|
store;
|
|
102
199
|
intervalMs;
|
|
200
|
+
remoteOpts;
|
|
103
201
|
timer = null;
|
|
104
|
-
constructor(basePath, store, intervalMs) {
|
|
202
|
+
constructor(basePath, store, intervalMs, remoteOpts) {
|
|
105
203
|
this.basePath = basePath;
|
|
106
204
|
this.store = store;
|
|
107
205
|
this.intervalMs = intervalMs;
|
|
206
|
+
this.remoteOpts = remoteOpts;
|
|
108
207
|
}
|
|
109
208
|
start() {
|
|
110
209
|
// Run once at startup, then on interval.
|
|
@@ -124,6 +223,14 @@ export class WorktreeGc {
|
|
|
124
223
|
catch (err) {
|
|
125
224
|
log.warn(TAG, `GC tick failed: ${err instanceof Error ? err.message : err}`);
|
|
126
225
|
}
|
|
226
|
+
if (this.remoteOpts) {
|
|
227
|
+
try {
|
|
228
|
+
pruneFailedRemoteBranches(this.remoteOpts);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
log.warn(TAG, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
127
234
|
}
|
|
128
235
|
}
|
|
129
236
|
function getRepoRoot() {
|
package/dist/worktree.d.ts
CHANGED
|
@@ -9,5 +9,10 @@ export declare function createWorktree(basePath: string, baseBranch: string, bra
|
|
|
9
9
|
export declare function cleanupWorktree(worktreePath: string, branchName?: string): void;
|
|
10
10
|
/**
|
|
11
11
|
* Generate a branch name from a card's short ID and title.
|
|
12
|
+
*
|
|
13
|
+
* Agent branches start under the failedBranchPrefix (default `agent-attempts/`)
|
|
14
|
+
* and are renamed to the approvedBranchPrefix (default `agent/`) only after the
|
|
15
|
+
* Review pipeline approves them. Branches under `agent-attempts/` are pruned
|
|
16
|
+
* by the GC after `failedAttemptRetentionDays`.
|
|
12
17
|
*/
|
|
13
|
-
export declare function makeBranchName(shortId: number, title: string): string;
|
|
18
|
+
export declare function makeBranchName(shortId: number, title: string, prefix?: string): string;
|
package/dist/worktree.js
CHANGED
|
@@ -158,8 +158,13 @@ export function cleanupWorktree(worktreePath, branchName) {
|
|
|
158
158
|
}
|
|
159
159
|
/**
|
|
160
160
|
* Generate a branch name from a card's short ID and title.
|
|
161
|
+
*
|
|
162
|
+
* Agent branches start under the failedBranchPrefix (default `agent-attempts/`)
|
|
163
|
+
* and are renamed to the approvedBranchPrefix (default `agent/`) only after the
|
|
164
|
+
* Review pipeline approves them. Branches under `agent-attempts/` are pruned
|
|
165
|
+
* by the GC after `failedAttemptRetentionDays`.
|
|
161
166
|
*/
|
|
162
|
-
export function makeBranchName(shortId, title) {
|
|
167
|
+
export function makeBranchName(shortId, title, prefix = "agent-attempts/") {
|
|
163
168
|
const slug = title
|
|
164
169
|
.toLowerCase()
|
|
165
170
|
.trim()
|
|
@@ -168,5 +173,5 @@ export function makeBranchName(shortId, title) {
|
|
|
168
173
|
.replace(/-+/g, "-")
|
|
169
174
|
.replace(/^-+|-+$/g, "")
|
|
170
175
|
.slice(0, 40);
|
|
171
|
-
return
|
|
176
|
+
return `${prefix}${shortId}-${slug || "task"}`;
|
|
172
177
|
}
|