@gethmy/harness 1.0.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/README.md +66 -0
- package/dist/cli.js +2936 -0
- package/dist/index.js +3734 -0
- package/package.json +65 -0
- package/src/artifact-judge.ts +410 -0
- package/src/cli.ts +272 -0
- package/src/command-metric.ts +594 -0
- package/src/error-classifier.ts +95 -0
- package/src/exec-types.ts +109 -0
- package/src/gate-collectors.ts +431 -0
- package/src/gate-config-error.ts +73 -0
- package/src/git-diff-stat.ts +148 -0
- package/src/git-pr.ts +839 -0
- package/src/harmony-client.ts +197 -0
- package/src/index.ts +37 -0
- package/src/log.ts +129 -0
- package/src/model-tier.test.ts +169 -0
- package/src/model-tier.ts +108 -0
- package/src/oracle-collector.ts +148 -0
- package/src/oracle.ts +434 -0
- package/src/pm.ts +73 -0
- package/src/process-group.ts +149 -0
- package/src/project-type.ts +303 -0
- package/src/revert-guard.ts +99 -0
- package/src/review-types.ts +52 -0
- package/src/runner.ts +184 -0
- package/src/sdk-agent-runner.ts +575 -0
- package/src/stage-cli.ts +302 -0
- package/src/stage-run.ts +91 -0
- package/src/verification.ts +711 -0
- package/src/worktree.ts +639 -0
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
import { installCommand } from "./pm.js";
|
|
6
|
+
|
|
7
|
+
const TAG = "worktree";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Minimal client surface the push-rescue needs — just the card-comment write.
|
|
11
|
+
* Kept structural (not a `HarmonyApiClient` import) so the rescue stays decoupled
|
|
12
|
+
* and is trivial to mock in unit tests.
|
|
13
|
+
*/
|
|
14
|
+
export interface RescueCommentClient {
|
|
15
|
+
addComment(
|
|
16
|
+
cardId: string,
|
|
17
|
+
body: string,
|
|
18
|
+
opts?: { commentType?: string },
|
|
19
|
+
): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Thrown when the base branch cannot be fetched from origin, so the worktree
|
|
24
|
+
* cannot be provably based on *current* main (card #408). The worker treats
|
|
25
|
+
* this as an infrastructure failure — requeue WITHOUT burning the give-up
|
|
26
|
+
* budget — rather than building the branch on a stale ref and silently
|
|
27
|
+
* reverting already-merged work.
|
|
28
|
+
*/
|
|
29
|
+
export class WorktreeBaseError extends Error {
|
|
30
|
+
constructor(message: string) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "WorktreeBaseError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fetch the base branch from origin, retrying transient failures. A new branch
|
|
38
|
+
* MUST be based on the current remote tip — silently falling back to a stale
|
|
39
|
+
* local `origin/<baseBranch>` ref is what produced the #408 wholesale-revert
|
|
40
|
+
* branches. After exhausting retries this throws `WorktreeBaseError`.
|
|
41
|
+
*
|
|
42
|
+
* `fetchImpl` is injectable so the retry/backoff contract is unit-testable
|
|
43
|
+
* without touching the network.
|
|
44
|
+
*/
|
|
45
|
+
export function fetchBaseBranch(
|
|
46
|
+
repoRoot: string,
|
|
47
|
+
baseBranch: string,
|
|
48
|
+
attempts = 3,
|
|
49
|
+
fetchImpl: (root: string, branch: string) => void = (root, branch) =>
|
|
50
|
+
execFileSync("git", ["fetch", "origin", branch], {
|
|
51
|
+
cwd: root,
|
|
52
|
+
stdio: "pipe",
|
|
53
|
+
}),
|
|
54
|
+
): void {
|
|
55
|
+
let lastErr: unknown;
|
|
56
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
fetchImpl(repoRoot, baseBranch);
|
|
59
|
+
return;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
lastErr = err;
|
|
62
|
+
log.warn(
|
|
63
|
+
TAG,
|
|
64
|
+
`fetch origin ${baseBranch} failed (attempt ${attempt}/${attempts})`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Prefer git's stderr (captured by `stdio: "pipe"`) over the generic
|
|
69
|
+
// "Command failed: …" message so connectivity failures are legible (#444).
|
|
70
|
+
const e = lastErr as { stderr?: unknown } | null;
|
|
71
|
+
const detail =
|
|
72
|
+
e?.stderr?.toString?.().trim() ||
|
|
73
|
+
(lastErr instanceof Error ? lastErr.message : String(lastErr));
|
|
74
|
+
throw new WorktreeBaseError(
|
|
75
|
+
`Could not fetch origin/${baseBranch} after ${attempts} attempts — ` +
|
|
76
|
+
`refusing to build on a stale base. ${detail}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decide which ref a new worktree's branch should be based on.
|
|
82
|
+
*
|
|
83
|
+
* The default is a fresh per-attempt branch from `origin/<baseBranch>` — a first
|
|
84
|
+
* implement attempt owns its branch, so resetting to current origin is correct.
|
|
85
|
+
* But two cases must instead continue the branch's OWN pushed tip: a Playbook
|
|
86
|
+
* *stage* run shares one per-card branch across stages (a later stage — or a
|
|
87
|
+
* gate-fail re-run — must build on the tip a prior stage pushed, or that commit
|
|
88
|
+
* is orphaned and the next stage sees an empty diff, #561); and a generic
|
|
89
|
+
* *rework* (attempt ≥ 2) must fix the implementation the review rejected rather
|
|
90
|
+
* than reimplement it from scratch (#689/#732). So when `continueExisting` is
|
|
91
|
+
* set and the branch is already on origin, base the worktree on
|
|
92
|
+
* `origin/<branchName>` (its real tip). A first attempt (or the first stage) has
|
|
93
|
+
* nothing pushed yet, so `branchExistsOnRemote` returns false and we fall back
|
|
94
|
+
* to a fresh branch — that path is unchanged.
|
|
95
|
+
*
|
|
96
|
+
* `branchExistsOnRemote` is a thunk so the decision is unit-testable without git,
|
|
97
|
+
* and is only consulted on the continuation path (generic runs never probe).
|
|
98
|
+
*/
|
|
99
|
+
export function resolveWorktreeStartRef(
|
|
100
|
+
baseBranch: string,
|
|
101
|
+
branchName: string,
|
|
102
|
+
continueExisting: boolean,
|
|
103
|
+
branchExistsOnRemote: () => boolean,
|
|
104
|
+
): string {
|
|
105
|
+
if (continueExisting && branchExistsOnRemote()) {
|
|
106
|
+
return `origin/${branchName}`;
|
|
107
|
+
}
|
|
108
|
+
return `origin/${baseBranch}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fetch a branch from origin so `origin/<branchName>` resolves locally, and
|
|
113
|
+
* report whether the branch exists on the remote. Connectivity has already been
|
|
114
|
+
* proven by the preceding `fetchBaseBranch()`, so a failure here means the ref
|
|
115
|
+
* simply isn't on origin yet (a fresh first-stage run) rather than a network
|
|
116
|
+
* fault — hence we return false instead of throwing.
|
|
117
|
+
*/
|
|
118
|
+
function fetchExistingBranch(repoRoot: string, branchName: string): boolean {
|
|
119
|
+
try {
|
|
120
|
+
execFileSync("git", ["fetch", "origin", branchName], {
|
|
121
|
+
cwd: repoRoot,
|
|
122
|
+
stdio: "pipe",
|
|
123
|
+
});
|
|
124
|
+
return true;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface CreateWorktreeOptions {
|
|
131
|
+
/**
|
|
132
|
+
* Continue an existing pushed branch from its origin tip instead of force-
|
|
133
|
+
* resetting it to `origin/<baseBranch>`. Set for Playbook stage runs (#561)
|
|
134
|
+
* and generic reworks (attempt ≥ 2, #689/#732). Falls back to a fresh branch
|
|
135
|
+
* when the branch isn't on origin yet.
|
|
136
|
+
*/
|
|
137
|
+
continueExisting?: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a git worktree for the agent to work in.
|
|
142
|
+
* Returns the absolute path to the new worktree.
|
|
143
|
+
*/
|
|
144
|
+
export function createWorktree(
|
|
145
|
+
basePath: string,
|
|
146
|
+
baseBranch: string,
|
|
147
|
+
branchName: string,
|
|
148
|
+
opts: CreateWorktreeOptions = {},
|
|
149
|
+
): string {
|
|
150
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
151
|
+
encoding: "utf-8",
|
|
152
|
+
}).trim();
|
|
153
|
+
|
|
154
|
+
const worktreeDir = resolve(repoRoot, basePath, branchName);
|
|
155
|
+
|
|
156
|
+
if (existsSync(worktreeDir)) {
|
|
157
|
+
log.warn(TAG, `Worktree already exists at ${worktreeDir}, cleaning up`);
|
|
158
|
+
cleanupWorktree(worktreeDir, branchName);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Prune stale worktree metadata. If a previous daemon crashed or its
|
|
162
|
+
// worktree dir was deleted externally, git may still think the branch is
|
|
163
|
+
// checked out, which blocks `git branch -D` and `git worktree add`.
|
|
164
|
+
// `--expire=now` overrides `gc.worktreePruneExpire` (default 3 months) so
|
|
165
|
+
// freshly-orphaned entries are removed immediately.
|
|
166
|
+
try {
|
|
167
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
168
|
+
cwd: repoRoot,
|
|
169
|
+
stdio: "pipe",
|
|
170
|
+
});
|
|
171
|
+
} catch {
|
|
172
|
+
// non-fatal
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Fetch latest from remote so the new branch is provably based on CURRENT
|
|
176
|
+
// origin/<baseBranch>. A persistent fetch failure throws WorktreeBaseError
|
|
177
|
+
// rather than silently building on a stale ref — the latter is what produced
|
|
178
|
+
// the #408 branches that wholesale-reverted already-merged work.
|
|
179
|
+
fetchBaseBranch(repoRoot, baseBranch);
|
|
180
|
+
|
|
181
|
+
// Pick the base ref. Normally a fresh per-attempt branch from
|
|
182
|
+
// origin/<baseBranch>; for a stage continuation, the branch's own pushed tip
|
|
183
|
+
// so a prior stage's commits survive into this run (#561). `-B` resets the
|
|
184
|
+
// local branch to whichever ref we chose.
|
|
185
|
+
const startRef = resolveWorktreeStartRef(
|
|
186
|
+
baseBranch,
|
|
187
|
+
branchName,
|
|
188
|
+
opts.continueExisting ?? false,
|
|
189
|
+
() => fetchExistingBranch(repoRoot, branchName),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
log.info(
|
|
193
|
+
TAG,
|
|
194
|
+
`Creating worktree: ${worktreeDir} (branch: ${branchName}, base: ${startRef})`,
|
|
195
|
+
);
|
|
196
|
+
try {
|
|
197
|
+
execFileSync(
|
|
198
|
+
"git",
|
|
199
|
+
["worktree", "add", "-B", branchName, worktreeDir, startRef],
|
|
200
|
+
{ cwd: repoRoot, stdio: "pipe" },
|
|
201
|
+
);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// Last-resort recovery: if `-B` still fails (e.g. branch checked out in
|
|
204
|
+
// another registered worktree), free the branch and retry.
|
|
205
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
206
|
+
log.warn(TAG, `worktree add failed, attempting forced recovery: ${msg}`);
|
|
207
|
+
// A DIFFERENT worktree may hold this branch — e.g. a `review-<branch>`
|
|
208
|
+
// worktree left behind by an interrupted review. git allows one worktree
|
|
209
|
+
// per branch, so both the retry AND the `git branch -D` below stay blocked
|
|
210
|
+
// until that holder is removed. Evict it first (#732). Removing our OWN
|
|
211
|
+
// target path is handled separately below (the holder is never our path
|
|
212
|
+
// here, but pass it as `exceptDir` to be safe).
|
|
213
|
+
removeWorktreeHoldingBranch(repoRoot, branchName, worktreeDir);
|
|
214
|
+
// Remove any registered worktree at this path (phantom or otherwise).
|
|
215
|
+
try {
|
|
216
|
+
execFileSync("git", ["worktree", "remove", worktreeDir, "--force"], {
|
|
217
|
+
cwd: repoRoot,
|
|
218
|
+
stdio: "pipe",
|
|
219
|
+
});
|
|
220
|
+
} catch {
|
|
221
|
+
// best-effort
|
|
222
|
+
}
|
|
223
|
+
// Force-prune any stale worktree admin entries referencing this branch.
|
|
224
|
+
try {
|
|
225
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
226
|
+
cwd: repoRoot,
|
|
227
|
+
stdio: "pipe",
|
|
228
|
+
});
|
|
229
|
+
} catch {
|
|
230
|
+
// best-effort
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
execFileSync("git", ["branch", "-D", branchName], {
|
|
234
|
+
cwd: repoRoot,
|
|
235
|
+
stdio: "pipe",
|
|
236
|
+
});
|
|
237
|
+
} catch {
|
|
238
|
+
// ignore; retry will surface the real error
|
|
239
|
+
}
|
|
240
|
+
execFileSync(
|
|
241
|
+
"git",
|
|
242
|
+
["worktree", "add", "-B", branchName, worktreeDir, startRef],
|
|
243
|
+
{ cwd: repoRoot, stdio: "pipe" },
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Install dependencies in the worktree
|
|
248
|
+
log.info(TAG, "Installing dependencies in worktree...");
|
|
249
|
+
try {
|
|
250
|
+
execSync(installCommand(), {
|
|
251
|
+
cwd: worktreeDir,
|
|
252
|
+
stdio: "pipe",
|
|
253
|
+
timeout: 60_000,
|
|
254
|
+
});
|
|
255
|
+
} catch {
|
|
256
|
+
log.warn(TAG, "Install failed (may be fine if deps are hoisted)");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return worktreeDir;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Remove a git worktree and its branch.
|
|
264
|
+
*/
|
|
265
|
+
export function cleanupWorktree(
|
|
266
|
+
worktreePath: string,
|
|
267
|
+
branchName?: string,
|
|
268
|
+
): void {
|
|
269
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
270
|
+
encoding: "utf-8",
|
|
271
|
+
}).trim();
|
|
272
|
+
|
|
273
|
+
if (existsSync(worktreePath)) {
|
|
274
|
+
try {
|
|
275
|
+
execFileSync("git", ["worktree", "remove", worktreePath, "--force"], {
|
|
276
|
+
cwd: repoRoot,
|
|
277
|
+
stdio: "pipe",
|
|
278
|
+
});
|
|
279
|
+
log.info(TAG, `Removed worktree: ${worktreePath}`);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
log.warn(
|
|
282
|
+
TAG,
|
|
283
|
+
`Failed to remove worktree cleanly: ${err instanceof Error ? err.message : err}`,
|
|
284
|
+
);
|
|
285
|
+
// Force-remove the directory if git worktree remove failed
|
|
286
|
+
if (existsSync(worktreePath)) {
|
|
287
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
// Prune stale worktree entries
|
|
290
|
+
try {
|
|
291
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
292
|
+
cwd: repoRoot,
|
|
293
|
+
stdio: "pipe",
|
|
294
|
+
});
|
|
295
|
+
} catch {
|
|
296
|
+
// best-effort
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
// Directory already gone — an idempotent re-teardown (e.g. completion tore
|
|
301
|
+
// the worktree down, then a racing cancel re-entered cleanup, #671). Calling
|
|
302
|
+
// `git worktree remove` here would always throw and log a misleading
|
|
303
|
+
// "Failed to remove worktree cleanly" warning. Quietly prune any stale
|
|
304
|
+
// registration instead; the branch delete below still runs.
|
|
305
|
+
try {
|
|
306
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
307
|
+
cwd: repoRoot,
|
|
308
|
+
stdio: "pipe",
|
|
309
|
+
});
|
|
310
|
+
} catch {
|
|
311
|
+
// best-effort
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Delete the branch so it doesn't block future runs
|
|
316
|
+
if (branchName) {
|
|
317
|
+
try {
|
|
318
|
+
execFileSync("git", ["branch", "-D", branchName], {
|
|
319
|
+
cwd: repoRoot,
|
|
320
|
+
stdio: "pipe",
|
|
321
|
+
});
|
|
322
|
+
} catch {
|
|
323
|
+
// best-effort
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Find the worktree (if any) currently holding `refs/heads/<branchName>` and,
|
|
330
|
+
* unless it is `exceptDir`, force-remove it so the branch is free to be
|
|
331
|
+
* (re)created elsewhere. Returns the evicted path, or `null` when no *other*
|
|
332
|
+
* worktree held the branch.
|
|
333
|
+
*
|
|
334
|
+
* Git permits only ONE worktree per branch. The implement branch
|
|
335
|
+
* `agent-attempts/<id>-<slug>` is checked out by the review worktree at a
|
|
336
|
+
* DIFFERENT path (`review-<branch>`), so when a review is interrupted
|
|
337
|
+
* (timeout / cancel / crash) its worktree can linger holding that branch. A
|
|
338
|
+
* later rework then fails at `git worktree add -B <branch> …` AND at the
|
|
339
|
+
* recovery's `git branch -D <branch>` with `fatal: '<branch>' is already used
|
|
340
|
+
* by worktree at …` — because both the create and the delete are blocked while
|
|
341
|
+
* a live worktree holds the ref. Evicting that holder first is the missing step
|
|
342
|
+
* (#732): the old recovery only ever removed the caller's OWN target path, never
|
|
343
|
+
* the foreign worktree actually pinning the branch.
|
|
344
|
+
*
|
|
345
|
+
* Best-effort and total: a `git worktree list` / remove failure returns `null`
|
|
346
|
+
* rather than throwing, leaving the caller's own error to surface.
|
|
347
|
+
*/
|
|
348
|
+
export function removeWorktreeHoldingBranch(
|
|
349
|
+
repoRoot: string,
|
|
350
|
+
branchName: string,
|
|
351
|
+
exceptDir?: string,
|
|
352
|
+
): string | null {
|
|
353
|
+
let listing: string;
|
|
354
|
+
try {
|
|
355
|
+
listing = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
|
356
|
+
cwd: repoRoot,
|
|
357
|
+
encoding: "utf-8",
|
|
358
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
359
|
+
});
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Porcelain output is newline-separated records; within a record a
|
|
365
|
+
// `worktree <path>` line is followed by an optional `branch refs/heads/<ref>`.
|
|
366
|
+
const target = `refs/heads/${branchName}`;
|
|
367
|
+
let currentPath: string | null = null;
|
|
368
|
+
let holderPath: string | null = null;
|
|
369
|
+
for (const line of listing.split("\n")) {
|
|
370
|
+
if (line.startsWith("worktree ")) {
|
|
371
|
+
currentPath = line.slice("worktree ".length).trim();
|
|
372
|
+
} else if (line.startsWith("branch ")) {
|
|
373
|
+
const ref = line.slice("branch ".length).trim();
|
|
374
|
+
if (ref === target && currentPath) {
|
|
375
|
+
holderPath = currentPath;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!holderPath) return null;
|
|
382
|
+
// Never evict the caller's own target worktree. Resolve both sides so a
|
|
383
|
+
// trailing-slash / symlink difference can't defeat the guard.
|
|
384
|
+
if (exceptDir && resolve(holderPath) === resolve(exceptDir)) return null;
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
execFileSync("git", ["worktree", "remove", holderPath, "--force"], {
|
|
388
|
+
cwd: repoRoot,
|
|
389
|
+
stdio: "pipe",
|
|
390
|
+
});
|
|
391
|
+
log.warn(
|
|
392
|
+
TAG,
|
|
393
|
+
`Evicted worktree ${holderPath} holding branch ${branchName} so it can be reused (#732)`,
|
|
394
|
+
);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
log.warn(
|
|
397
|
+
TAG,
|
|
398
|
+
`Failed to evict worktree ${holderPath} holding ${branchName}: ${
|
|
399
|
+
err instanceof Error ? err.message : err
|
|
400
|
+
}`,
|
|
401
|
+
);
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
// Clear the now-orphaned admin entry so `git branch -D` / `worktree add` see a
|
|
405
|
+
// free branch.
|
|
406
|
+
try {
|
|
407
|
+
execFileSync("git", ["worktree", "prune", "--expire=now"], {
|
|
408
|
+
cwd: repoRoot,
|
|
409
|
+
stdio: "pipe",
|
|
410
|
+
});
|
|
411
|
+
} catch {
|
|
412
|
+
// best-effort
|
|
413
|
+
}
|
|
414
|
+
return holderPath;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Resolve the main repo root, independent of any worktree cwd. */
|
|
418
|
+
function resolveRepoRoot(): string {
|
|
419
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
420
|
+
encoding: "utf-8",
|
|
421
|
+
}).trim();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Does a local branch ref `refs/heads/<branchName>` exist? Used to make teardown
|
|
426
|
+
* idempotent: a re-teardown (or a teardown of a never-created branch) must not
|
|
427
|
+
* probe a missing ref. `git show-ref --verify --quiet` exits non-zero (no
|
|
428
|
+
* stderr) when the ref is absent.
|
|
429
|
+
*/
|
|
430
|
+
function localBranchExists(branchName: string, repoRoot: string): boolean {
|
|
431
|
+
try {
|
|
432
|
+
execFileSync(
|
|
433
|
+
"git",
|
|
434
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`],
|
|
435
|
+
{ cwd: repoRoot, stdio: "ignore" },
|
|
436
|
+
);
|
|
437
|
+
return true;
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Does `branchName` hold commits that are NOT yet on origin? — the
|
|
445
|
+
* "committed-but-unpushed" detector the package otherwise lacks (card #587).
|
|
446
|
+
*
|
|
447
|
+
* Compares the branch to ITS OWN remote-tracking refs, NOT to base. A base diff
|
|
448
|
+
* (`origin/<base>..HEAD`, what `completion.checkHasCommits` does) is true on every
|
|
449
|
+
* feature branch even after a clean push, so reusing it here would fire a
|
|
450
|
+
* redundant rescue + spurious comment on the already-pushed failed-verify path.
|
|
451
|
+
* `git rev-list <branch> --not --remotes=origin` lists commits reachable from the
|
|
452
|
+
* branch but from no origin ref; non-empty ⇒ the branch ref is not fully durable
|
|
453
|
+
* on origin — only then is a rescue warranted.
|
|
454
|
+
*
|
|
455
|
+
* Runs with `cwd = main repo root` so it still works after the worktree dir was
|
|
456
|
+
* removed (e.g. GC ran before recovery). Returns false on any git error — fail
|
|
457
|
+
* safe toward the existing delete behaviour rather than block teardown on a flaky
|
|
458
|
+
* git call.
|
|
459
|
+
*/
|
|
460
|
+
export function branchAheadOfItsRemote(
|
|
461
|
+
branchName: string,
|
|
462
|
+
repoRoot = resolveRepoRoot(),
|
|
463
|
+
): boolean {
|
|
464
|
+
// A deleted (or never-created) branch has nothing to rescue — and running
|
|
465
|
+
// `git rev-list` on a missing ref prints `fatal: ambiguous argument
|
|
466
|
+
// '<branch>': unknown revision or path...` to stderr. Gate on ref existence so
|
|
467
|
+
// an idempotent second teardown is a clean no-op instead of a spurious fatal
|
|
468
|
+
// in the daemon log (#671).
|
|
469
|
+
if (!localBranchExists(branchName, repoRoot)) return false;
|
|
470
|
+
try {
|
|
471
|
+
const out = execFileSync(
|
|
472
|
+
"git",
|
|
473
|
+
["rev-list", branchName, "--not", "--remotes=origin"],
|
|
474
|
+
// stdio pipes stderr (rather than inheriting it) so any git diagnostic is
|
|
475
|
+
// captured with the throw, never leaked to the daemon's own stderr.
|
|
476
|
+
{ cwd: repoRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
|
477
|
+
).trim();
|
|
478
|
+
return out.length > 0;
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Push an about-to-be-deleted branch to origin under its OWN name so the
|
|
486
|
+
* committed work is durable before teardown destroys the local ref (card #587).
|
|
487
|
+
*
|
|
488
|
+
* Pushes the existing `agent-attempts/<shortId>-<slug>` name — no new ref, no
|
|
489
|
+
* timestamp — so it dedups with completion's failed-verify push and inherits the
|
|
490
|
+
* existing `failedAttemptRetentionDays` GC. Runs from the main repo root (decision
|
|
491
|
+
* #3: a branch push needs no worktree cwd, so the rescue still works when the
|
|
492
|
+
* worktree dir is already gone).
|
|
493
|
+
*
|
|
494
|
+
* On success, posts ONE board comment with the recovery ref + a `git fetch &&
|
|
495
|
+
* git checkout` hint. Once-ness is structural, not a dedup helper: a successful
|
|
496
|
+
* rescue lets the caller delete the branch, so the teardown path cannot re-enter
|
|
497
|
+
* for that branch; a failed rescue keeps the branch but (decision #4) does not
|
|
498
|
+
* comment.
|
|
499
|
+
*
|
|
500
|
+
* Returns `true` only when the push landed. A failed push returns `false` so the
|
|
501
|
+
* caller leaves the local branch ref intact — never `git branch -D` work that is
|
|
502
|
+
* not durable somewhere.
|
|
503
|
+
*/
|
|
504
|
+
export async function rescueUnpushedBranch(
|
|
505
|
+
client: RescueCommentClient,
|
|
506
|
+
cardId: string,
|
|
507
|
+
branchName: string,
|
|
508
|
+
repoRoot = resolveRepoRoot(),
|
|
509
|
+
): Promise<boolean> {
|
|
510
|
+
// Imported lazily to keep git-pr.ts's ~600 lines of PR/provider logic out of
|
|
511
|
+
// worktree.ts's static dependency graph. git-pr.ts no longer does any work
|
|
512
|
+
// at module scope (execFileAsync there is lazily created on first call, not
|
|
513
|
+
// hoisted), so this is no longer about protecting a partial
|
|
514
|
+
// `node:child_process` mock from a top-level side effect — it is a plain
|
|
515
|
+
// load-on-demand: the rescue path is the only place worktree.ts needs
|
|
516
|
+
// git-pr, and every other worktree consumer (worktree-gc, review-worktree,
|
|
517
|
+
// …) shouldn't pay to load it.
|
|
518
|
+
const { getBranchWebUrl, pushBranch } = await import("./git-pr.js");
|
|
519
|
+
try {
|
|
520
|
+
pushBranch(branchName, repoRoot);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
log.error(
|
|
523
|
+
TAG,
|
|
524
|
+
`push-rescue failed for ${branchName} — leaving local branch ref intact (recoverable via git reflog / the local branch): ${
|
|
525
|
+
err instanceof Error ? err.message : err
|
|
526
|
+
}`,
|
|
527
|
+
);
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
log.warn(
|
|
532
|
+
TAG,
|
|
533
|
+
`push-rescued unpushed branch ${branchName} to origin before teardown`,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
// Comment is best-effort: a failed comment must NOT undo a successful rescue
|
|
537
|
+
// (the work is already durable on origin).
|
|
538
|
+
try {
|
|
539
|
+
const url = getBranchWebUrl(branchName, repoRoot);
|
|
540
|
+
const recover = url
|
|
541
|
+
? `View it at ${url} or recover locally: \`git fetch && git checkout ${branchName}\``
|
|
542
|
+
: `Recover it locally: \`git fetch && git checkout ${branchName}\``;
|
|
543
|
+
const body =
|
|
544
|
+
`⚠ Run ended before completion. Committed work was push-rescued to ` +
|
|
545
|
+
`\`origin/${branchName}\` so it isn't lost. ${recover}`;
|
|
546
|
+
await client.addComment(cardId, body, { commentType: "message" });
|
|
547
|
+
} catch (err) {
|
|
548
|
+
log.warn(
|
|
549
|
+
TAG,
|
|
550
|
+
`push-rescue comment failed for ${branchName} (work is still safe on origin): ${
|
|
551
|
+
err instanceof Error ? err.message : err
|
|
552
|
+
}`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Teardown chokepoint with a push-rescue guard (card #587). Every destructive,
|
|
561
|
+
* branch-passing teardown (completion, recovery, watchdog requeue, defensive
|
|
562
|
+
* cleanup) routes through here so it inherits the "push before you destroy"
|
|
563
|
+
* guarantee the completion path already had — closing the dangling-commit hole
|
|
564
|
+
* where a committed-but-never-pushed branch was `git branch -D`'d into an
|
|
565
|
+
* unreachable object.
|
|
566
|
+
*
|
|
567
|
+
* If the branch holds commits not on origin, push-rescue it first; if the rescue
|
|
568
|
+
* push fails, KEEP the local branch ref (skip the delete) so the commit stays
|
|
569
|
+
* recoverable. Then delegate to `cleanupWorktree` for the (unchanged) worktree
|
|
570
|
+
* removal + conditional branch delete.
|
|
571
|
+
*
|
|
572
|
+
* When the branch is already on origin (the happy + failed-verify completion
|
|
573
|
+
* paths, which push first) the predicate is false and this is a no-op rescue —
|
|
574
|
+
* behaviour-identical to the old raw `cleanupWorktree(path, branch)`. The GC
|
|
575
|
+
* sweep (no branch) never calls this; it stays on raw synchronous
|
|
576
|
+
* `cleanupWorktree`.
|
|
577
|
+
*/
|
|
578
|
+
export async function teardownWorktree(
|
|
579
|
+
client: RescueCommentClient,
|
|
580
|
+
cardId: string | null | undefined,
|
|
581
|
+
worktreePath: string,
|
|
582
|
+
branchName?: string,
|
|
583
|
+
): Promise<void> {
|
|
584
|
+
let skipBranchDelete = false;
|
|
585
|
+
|
|
586
|
+
if (branchName && cardId) {
|
|
587
|
+
let repoRoot: string;
|
|
588
|
+
try {
|
|
589
|
+
repoRoot = resolveRepoRoot();
|
|
590
|
+
} catch {
|
|
591
|
+
// Can't resolve the repo — fall back to a plain cleanup rather than risk
|
|
592
|
+
// a bogus rescue. cleanupWorktree resolves the root itself.
|
|
593
|
+
cleanupWorktree(worktreePath, branchName);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (branchAheadOfItsRemote(branchName, repoRoot)) {
|
|
598
|
+
const ok = await rescueUnpushedBranch(
|
|
599
|
+
client,
|
|
600
|
+
cardId,
|
|
601
|
+
branchName,
|
|
602
|
+
repoRoot,
|
|
603
|
+
);
|
|
604
|
+
if (!ok) {
|
|
605
|
+
skipBranchDelete = true;
|
|
606
|
+
log.error(
|
|
607
|
+
TAG,
|
|
608
|
+
`Keeping local branch ${branchName} (push-rescue failed) to avoid orphaning its commit`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
cleanupWorktree(worktreePath, skipBranchDelete ? undefined : branchName);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Generate a branch name from a card's short ID and title.
|
|
619
|
+
*
|
|
620
|
+
* Agent branches start under the failedBranchPrefix (default `agent-attempts/`)
|
|
621
|
+
* and are renamed to the approvedBranchPrefix (default `agent/`) only after the
|
|
622
|
+
* Review pipeline approves them. Branches under `agent-attempts/` are pruned
|
|
623
|
+
* by the GC after `failedAttemptRetentionDays`.
|
|
624
|
+
*/
|
|
625
|
+
export function makeBranchName(
|
|
626
|
+
shortId: number,
|
|
627
|
+
title: string,
|
|
628
|
+
prefix = "agent-attempts/",
|
|
629
|
+
): string {
|
|
630
|
+
const slug = title
|
|
631
|
+
.toLowerCase()
|
|
632
|
+
.trim()
|
|
633
|
+
.replace(/[^\w\s-]/g, "")
|
|
634
|
+
.replace(/\s+/g, "-")
|
|
635
|
+
.replace(/-+/g, "-")
|
|
636
|
+
.replace(/^-+|-+$/g, "")
|
|
637
|
+
.slice(0, 40);
|
|
638
|
+
return `${prefix}${shortId}-${slug || "task"}`;
|
|
639
|
+
}
|