@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.
@@ -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
+ }