@ai-dossier/worktree-pool 0.5.2 → 0.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.
@@ -33,8 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ReturnFailure = void 0;
36
37
  exports.readPoolFileConfig = readPoolFileConfig;
37
38
  exports.resolvePoolDir = resolvePoolDir;
39
+ exports.findBrokenEntries = findBrokenEntries;
38
40
  exports.replenish = replenish;
39
41
  exports.claim = claim;
40
42
  exports.returnWorktree = returnWorktree;
@@ -346,6 +348,53 @@ function lockfilePathForWorktree(worktreeAbsPath, cfg) {
346
348
  const projectDir = (0, project_env_1.resolveProjectDir)(worktreeAbsPath, cfg.project_subdir);
347
349
  return (0, project_env_1.lockfilePathFor)((0, project_env_1.detectProjectEnv)(projectDir), cfg.project_subdir);
348
350
  }
351
+ /**
352
+ * Did the project lockfile change between `baseCommit` and the configured base
353
+ * ref? An unreadable diff counts as "changed" — re-warming is cheap, a stale
354
+ * `node_modules` is not.
355
+ */
356
+ function lockfileChangedSince(gitRoot, baseCommit, worktreeAbsPath, cfg) {
357
+ try {
358
+ const diff = git(['diff', '--name-only', `${baseCommit}..${cfg.base_ref}`], { cwd: gitRoot });
359
+ return (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePathForWorktree(worktreeAbsPath, cfg));
360
+ }
361
+ catch {
362
+ return true;
363
+ }
364
+ }
365
+ /** Message of a thrown value, whatever it is. */
366
+ function messageOf(err) {
367
+ return err instanceof Error ? err.message : String(err);
368
+ }
369
+ /**
370
+ * Make git's error text safe to persist and re-print. `.pool-state.json` is a
371
+ * plain file that `status` echoes back, and git quotes remote URLs verbatim in
372
+ * its errors — including any `https://user:token@host` credentials — so an
373
+ * unfiltered message would write a secret to disk and replay terminal escape
374
+ * sequences from a remote on every later `status`.
375
+ */
376
+ function sanitizeReason(text) {
377
+ return (text
378
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, '$1<redacted>@')
379
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escapes is the point
380
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
381
+ .slice(0, 2000));
382
+ }
383
+ /**
384
+ * The value to record as an entry's `path`. State paths are contractually bare
385
+ * directory names directly inside the pool directory (`classifyPath` matches on
386
+ * `basename`), so anything that escapes — from a hand-edited or corrupted state
387
+ * file — keeps the previously recorded name rather than being written back.
388
+ */
389
+ function relativePoolPath(poolDir, onDiskPath, fallback) {
390
+ const rel = path.relative(poolDir, onDiskPath);
391
+ const isBareSegment = rel.length > 0 &&
392
+ !path.isAbsolute(rel) &&
393
+ !rel.startsWith('..') &&
394
+ !rel.includes(path.sep) &&
395
+ rel !== '.';
396
+ return isBareSegment ? rel : fallback;
397
+ }
349
398
  // --- Ownership / provenance ---
350
399
  //
351
400
  // The pool directory is routinely shared with worktrees a developer made by
@@ -361,6 +410,52 @@ function realpathOrSelf(p) {
361
410
  return path.resolve(p);
362
411
  }
363
412
  }
413
+ /**
414
+ * Absolute path of the git admin dir a linked worktree points at, or `null`
415
+ * when the directory carries no readable `.git` link. A directory holding a
416
+ * real `.git` directory (a whole repo, not a linked worktree) reports itself.
417
+ */
418
+ function resolveWorktreeAdminDir(absPath) {
419
+ const dotGit = path.join(absPath, '.git');
420
+ let stat;
421
+ try {
422
+ stat = fs.statSync(dotGit);
423
+ }
424
+ catch {
425
+ return null;
426
+ }
427
+ if (stat.isDirectory())
428
+ return dotGit;
429
+ let raw;
430
+ try {
431
+ raw = fs.readFileSync(dotGit, 'utf-8');
432
+ }
433
+ catch {
434
+ return null;
435
+ }
436
+ const match = /^gitdir:\s*(.+)$/m.exec(raw);
437
+ if (!match)
438
+ return null;
439
+ const target = match[1].trim();
440
+ if (target.length === 0)
441
+ return null;
442
+ return path.isAbsolute(target) ? target : path.resolve(absPath, target);
443
+ }
444
+ /**
445
+ * True when a directory that is on disk has lost the git admin dir behind it.
446
+ *
447
+ * This is the #443 corruption: a rename repaired without its new path leaves a
448
+ * dangling `gitdir` forward link, and the next `git worktree prune` deletes
449
+ * `.git/worktrees/<id>`. The directory survives with a `.git` file pointing at
450
+ * nothing, so every git command inside it fails with
451
+ * `fatal: not a git repository`.
452
+ */
453
+ function isWorktreeAdminDirMissing(absPath) {
454
+ if (!fs.existsSync(absPath))
455
+ return false;
456
+ const adminDir = resolveWorktreeAdminDir(absPath);
457
+ return adminDir === null || !fs.existsSync(adminDir);
458
+ }
364
459
  /**
365
460
  * Absolute worktree path -> checked-out branch, from `git worktree list`.
366
461
  * A registered worktree with a detached HEAD maps to `null`.
@@ -410,6 +505,7 @@ function classifyPath(ctx, absPath) {
410
505
  branch: ctx.branches.get(resolved) ?? null,
411
506
  provenance: 'foreign',
412
507
  owned: false,
508
+ broken: false,
413
509
  reason: `outside the pool directory (${poolDirReal})`,
414
510
  };
415
511
  }
@@ -420,6 +516,7 @@ function classifyPath(ctx, absPath) {
420
516
  registered,
421
517
  existsOnDisk: fs.existsSync(resolved),
422
518
  stateEntry: ctx.state.worktrees.find((w) => w.path === name) ?? null,
519
+ adminDirMissing: registered ? false : isWorktreeAdminDirMissing(resolved),
423
520
  });
424
521
  return {
425
522
  name,
@@ -441,10 +538,31 @@ function scanPoolDir(ctx) {
441
538
  }
442
539
  return reports;
443
540
  }
444
- /** Foreign worktrees found in the pool directory, for reporting. */
445
- function foreignInPoolDir(gitRoot, poolDir, state) {
446
- const ctx = buildOwnershipContext(gitRoot, poolDir, state);
447
- return scanPoolDir(ctx).filter((e) => !e.owned);
541
+ /**
542
+ * Split the pool directory into the two things worth reporting: worktrees the
543
+ * pool did not create, and entries corrupted on disk (#443).
544
+ *
545
+ * A corrupted entry is listed only under `broken` — reporting it as foreign as
546
+ * well would read as two separate problems with one directory.
547
+ */
548
+ function reportPoolDirEntries(gitRoot, poolDir, state) {
549
+ const entries = scanPoolDir(buildOwnershipContext(gitRoot, poolDir, state));
550
+ const broken = entries.filter((e) => e.broken);
551
+ const brokenPaths = new Set(broken.map((e) => e.path));
552
+ return {
553
+ foreign: entries.filter((e) => !e.owned && !brokenPaths.has(e.path)),
554
+ broken,
555
+ };
556
+ }
557
+ /**
558
+ * Corrupted pool directories: still on disk, but git has no admin dir for them
559
+ * (#443). Reported so a broken pool is visible rather than surfacing as a raw
560
+ * `fatal: not a git repository` from whichever command touched it first.
561
+ */
562
+ function findBrokenEntries() {
563
+ const gitRoot = findGitRoot();
564
+ const poolDir = resolvePoolDirSync(gitRoot);
565
+ return reportPoolDirEntries(gitRoot, poolDir).broken;
448
566
  }
449
567
  // --- Destroy helper ---
450
568
  /**
@@ -567,8 +685,19 @@ function claim(issue, branch) {
567
685
  const gitRoot = findGitRoot();
568
686
  const cfg = readPoolFileConfig(gitRoot);
569
687
  const poolDir = resolvePoolDirSync(gitRoot);
688
+ // A warm entry whose directory lost its git admin dir would fail every git
689
+ // command with a raw `fatal: not a git repository`. Skip it and take the
690
+ // next warm spare instead, reporting what was passed over.
691
+ const brokenSkipped = [];
570
692
  const result = withLock(poolDir, (state) => {
571
- const claimed = (0, pool_state_1.claimWorktree)(state, issue, branch);
693
+ const ctx = buildOwnershipContext(gitRoot, poolDir, state);
694
+ const claimed = (0, pool_state_1.claimWorktree)(state, issue, branch, (candidate) => {
695
+ const report = classifyPath(ctx, toAbs(poolDir, candidate.path));
696
+ if (!report.broken)
697
+ return true;
698
+ brokenSkipped.push(report);
699
+ return false;
700
+ });
572
701
  if (!claimed)
573
702
  return { state, result: null };
574
703
  return { state: claimed.state, result: claimed.worktree };
@@ -583,17 +712,7 @@ function claim(issue, branch) {
583
712
  // Check freshness
584
713
  const currentBase = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
585
714
  if (worktree.base_commit !== currentBase) {
586
- const lockfilePath = lockfilePathForWorktree(absPath, cfg);
587
- let lockChanged = false;
588
- try {
589
- const diff = git(['diff', '--name-only', `${worktree.base_commit}..${cfg.base_ref}`], {
590
- cwd: gitRoot,
591
- });
592
- lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
593
- }
594
- catch {
595
- lockChanged = true;
596
- }
715
+ const lockChanged = lockfileChangedSince(gitRoot, worktree.base_commit, absPath, cfg);
597
716
  git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: absPath });
598
717
  git(['reset', '--hard', cfg.base_ref], { cwd: absPath });
599
718
  if (lockChanged) {
@@ -602,10 +721,15 @@ function claim(issue, branch) {
602
721
  }
603
722
  // Create issue branch
604
723
  git(['checkout', '-b', branch], { cwd: absPath });
605
- // Rename directory
724
+ // Rename directory.
725
+ // `git worktree repair` must be given the *new* path: a pathless repair
726
+ // only fixes the worktree->repo back-link, leaving the repo-side
727
+ // `.git/worktrees/<id>/gitdir` forward link pointing at the old location.
728
+ // The next `git worktree prune` then sees a dangling gitdir, deletes the
729
+ // admin dir, and the renamed worktree is corrupted (#443).
606
730
  if (absPath !== newAbsPath && !fs.existsSync(newAbsPath)) {
607
731
  fs.renameSync(absPath, newAbsPath);
608
- git(['worktree', 'repair'], { cwd: gitRoot });
732
+ git(['worktree', 'repair', newAbsPath], { cwd: gitRoot });
609
733
  }
610
734
  // Delete temp branch
611
735
  try {
@@ -623,7 +747,7 @@ function claim(issue, branch) {
623
747
  }),
624
748
  result: undefined,
625
749
  }));
626
- return { path: newAbsPath };
750
+ return { path: newAbsPath, broken: brokenSkipped };
627
751
  }
628
752
  catch (err) {
629
753
  // Revert claim on failure
@@ -638,6 +762,139 @@ function claim(issue, branch) {
638
762
  throw err;
639
763
  }
640
764
  }
765
+ /**
766
+ * A `return` that failed, tagged with the step that failed (#453 AC1). The
767
+ * pool entry has been left in `status: 'broken'` — not `assigned`, not `warm`
768
+ * — and the worktree directory is deliberately NOT destroyed, so the failure
769
+ * is inspectable and `gc` can clear it deliberately.
770
+ *
771
+ * `markError` is the one case where that promise could not be kept: the
772
+ * broken-marking write itself failed. Callers must report it rather than
773
+ * assume the entry is marked, or they reproduce the very bug #453 is about.
774
+ */
775
+ class ReturnFailure extends Error {
776
+ step;
777
+ entryId;
778
+ worktreePath;
779
+ /** Why the entry could NOT be marked broken, or `null` when it was marked. */
780
+ markError;
781
+ constructor(step, worktreePath, entryId, cause, markError = null) {
782
+ super(`return failed at step '${step}': ${messageOf(cause)}`, { cause });
783
+ this.name = 'ReturnFailure';
784
+ this.step = step;
785
+ this.entryId = entryId;
786
+ this.worktreePath = worktreePath;
787
+ this.markError = markError;
788
+ }
789
+ }
790
+ exports.ReturnFailure = ReturnFailure;
791
+ /** Run `fn`, tagging any throw with the step it happened in. */
792
+ function step(name, fn) {
793
+ try {
794
+ return fn();
795
+ }
796
+ catch (err) {
797
+ // Kept so that if a step is ever nested inside another, the innermost
798
+ // attribution wins rather than being overwritten by its caller.
799
+ if (err instanceof StepError)
800
+ throw err;
801
+ throw new StepError(name, err);
802
+ }
803
+ }
804
+ /** Internal carrier so `step` can attribute a failure without unwinding twice. */
805
+ class StepError extends Error {
806
+ step;
807
+ constructor(stepName, cause) {
808
+ super(messageOf(cause), { cause });
809
+ this.name = 'StepError';
810
+ this.step = stepName;
811
+ }
812
+ }
813
+ /** Branch checked out at `absPath`, or `null` when it cannot be determined. */
814
+ function currentBranchOf(absPath) {
815
+ try {
816
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: absPath });
817
+ return branch === 'HEAD' ? null : branch;
818
+ }
819
+ catch {
820
+ return null;
821
+ }
822
+ }
823
+ /**
824
+ * Tracked-file cleanliness of `absPath`. A git failure is reported as an error
825
+ * rather than as "dirty" — telling an operator to look for uncommitted changes
826
+ * that do not exist is the wrong place to send them.
827
+ */
828
+ function inspectWorktreeCleanliness(absPath) {
829
+ let output;
830
+ try {
831
+ output = git(['status', '--porcelain', '--untracked-files=no'], { cwd: absPath });
832
+ }
833
+ catch (err) {
834
+ return { clean: false, entries: [`git status failed: ${messageOf(err)}`] };
835
+ }
836
+ const entries = output.length === 0 ? [] : output.split('\n');
837
+ return { clean: entries.length === 0, entries };
838
+ }
839
+ /**
840
+ * Verify a finished recycle against reality (#453 AC2). Separate from
841
+ * `returnWorktree` so the rules are readable and testable on their own.
842
+ */
843
+ function verifyRecycled(poolDir, newId, newAbsPath, newTempBranch) {
844
+ const committed = readState(poolDir).worktrees.find((w) => w.id === newId);
845
+ const cleanliness = inspectWorktreeCleanliness(newAbsPath);
846
+ const result = {
847
+ entry_status: committed?.status ?? 'missing',
848
+ directory_clean: cleanliness.clean,
849
+ dirty_entries: cleanliness.entries,
850
+ checked_out_branch: currentBranchOf(newAbsPath),
851
+ expected_branch: newTempBranch,
852
+ };
853
+ const problems = [];
854
+ if (result.entry_status !== 'warm') {
855
+ problems.push(`entry status is '${result.entry_status}', expected 'warm'`);
856
+ }
857
+ if (!result.directory_clean) {
858
+ problems.push(`directory ${newAbsPath} is not clean: ${result.dirty_entries.slice(0, 5).join(', ')}`);
859
+ }
860
+ if (result.checked_out_branch !== newTempBranch) {
861
+ problems.push(`checked-out branch is '${result.checked_out_branch ?? 'detached HEAD'}', ` +
862
+ `expected '${newTempBranch}'`);
863
+ }
864
+ if (problems.length > 0) {
865
+ throw new Error(`post-return self-check failed: ${problems.join('; ')}`);
866
+ }
867
+ return result;
868
+ }
869
+ /**
870
+ * Mark a pool entry `broken` after a failed recycle. Returns `null` on
871
+ * success, or the reason the mark itself failed — never throws, because the
872
+ * caller is already reporting a failure and must not lose it, but never
873
+ * silently either: an unmarked entry is the #453 bug.
874
+ */
875
+ function markEntryBroken(poolDir, entryId, updates) {
876
+ try {
877
+ withLock(poolDir, (state) => ({
878
+ state: (0, pool_state_1.updateWorktree)(state, entryId, updates),
879
+ result: undefined,
880
+ }));
881
+ return null;
882
+ }
883
+ catch (err) {
884
+ return messageOf(err);
885
+ }
886
+ }
887
+ /**
888
+ * Recycle an assigned worktree back to `warm`.
889
+ *
890
+ * Transactional in outcome (#453): it either finishes and returns a verified
891
+ * `warm` entry, or it leaves the entry `broken` and throws a `ReturnFailure`
892
+ * naming the step. It never leaves the entry `assigned`, never reports success
893
+ * over an unverified pool, and — unlike the pre-#453 behaviour — never
894
+ * destroys the worktree on failure. A broken entry keeps its provenance
895
+ * (recorded id, actual on-disk path, a pool-owned temp branch) so `status`
896
+ * shows it and `gc` can remove it.
897
+ */
641
898
  function returnWorktree(worktreePath) {
642
899
  const gitRoot = findGitRoot();
643
900
  const cfg = readPoolFileConfig(gitRoot);
@@ -646,53 +903,73 @@ function returnWorktree(worktreePath) {
646
903
  const newId = (0, types_1.generateId)();
647
904
  const newTempBranch = `pool/spare-${newId}`;
648
905
  const newAbsPath = toAbs(poolDir, newId);
649
- // Look up entry and mark as recycling atomically under lock
650
- const entry = withLock(poolDir, (state) => {
651
- const found = state.worktrees.find((w) => toAbs(poolDir, w.path) === absPath);
652
- if (!found) {
653
- throw new Error(`Worktree not found in pool state: ${worktreePath}`);
654
- }
655
- return {
656
- state: (0, pool_state_1.updateWorktree)(state, found.id, { status: 'recycling' }),
657
- result: { ...found },
658
- };
659
- });
906
+ // Look up the entry and mark it `recycling`, atomically. This is the one
907
+ // failure with no entry to mark broken — there is no entry — but it still
908
+ // speaks the ReturnFailure contract so the CLI and library callers get the
909
+ // step name rather than a bare internal error.
910
+ let entry;
660
911
  try {
661
- git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: absPath });
662
- git(['checkout', '-b', newTempBranch, cfg.base_ref], { cwd: absPath });
663
- git(['clean', '-fd'], { cwd: absPath });
664
- if (entry.assigned_branch) {
665
- try {
666
- git(['branch', '-D', entry.assigned_branch], { cwd: absPath });
912
+ entry = withLock(poolDir, (state) => {
913
+ const found = state.worktrees.find((w) => toAbs(poolDir, w.path) === absPath);
914
+ if (!found) {
915
+ throw new Error(`Worktree not found in pool state: ${worktreePath} (resolved to ${absPath}); ` +
916
+ `pool dir ${poolDir} tracks ${state.worktrees.length} entries`);
667
917
  }
668
- catch {
669
- // may not exist
918
+ return {
919
+ state: (0, pool_state_1.updateWorktree)(state, found.id, { status: 'recycling' }),
920
+ result: { ...found },
921
+ };
922
+ });
923
+ }
924
+ catch (err) {
925
+ throw new ReturnFailure('lookup', absPath, null, err);
926
+ }
927
+ // Where the directory, its branch and its state row actually are right now.
928
+ // Updated as the steps progress so a failure after a partial rename — or
929
+ // after `commit-state` has already renamed the entry id — records the truth
930
+ // rather than the values we started from.
931
+ let livePath = absPath;
932
+ let liveTempBranch = entry.temp_branch;
933
+ let liveId = entry.id;
934
+ try {
935
+ step('fetch', () => git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: livePath }));
936
+ step('checkout-temp-branch', () => git(['checkout', '-b', newTempBranch, cfg.base_ref], { cwd: livePath }));
937
+ liveTempBranch = newTempBranch;
938
+ step('clean', () => git(['clean', '-fd'], { cwd: livePath }));
939
+ const assignedBranch = entry.assigned_branch;
940
+ if (assignedBranch) {
941
+ // Best-effort: the branch may already be gone. Anything else is worth
942
+ // saying out loud — a surviving branch collides on the next claim.
943
+ try {
944
+ git(['branch', '-D', assignedBranch], { cwd: livePath });
670
945
  }
671
- }
672
- if (absPath !== newAbsPath) {
673
- if (fs.existsSync(newAbsPath)) {
674
- throw new Error(`Recycle target already exists: ${newAbsPath}`);
946
+ catch (err) {
947
+ const msg = messageOf(err);
948
+ if (!/not found|no branch named/i.test(msg)) {
949
+ console.error(`Warning: could not delete assigned branch '${assignedBranch}': ${msg}`);
950
+ }
675
951
  }
676
- fs.renameSync(absPath, newAbsPath);
677
- git(['worktree', 'repair'], { cwd: gitRoot });
678
952
  }
679
- const currentBase = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
680
- const lockfilePath = lockfilePathForWorktree(newAbsPath, cfg);
681
- let lockChanged = false;
682
- try {
683
- const diff = git(['diff', '--name-only', `${entry.base_commit}..${cfg.base_ref}`], {
684
- cwd: gitRoot,
953
+ if (livePath !== newAbsPath) {
954
+ step('rename', () => {
955
+ if (fs.existsSync(newAbsPath)) {
956
+ throw new Error(`Recycle target already exists: ${newAbsPath}`);
957
+ }
958
+ fs.renameSync(livePath, newAbsPath);
685
959
  });
686
- lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
687
- }
688
- catch {
689
- lockChanged = true;
960
+ livePath = newAbsPath;
961
+ // Repair the moved worktree by its new path — see the note in `claim`;
962
+ // a pathless repair leaves a dangling gitdir for `prune` to delete (#443).
963
+ step('repair', () => git(['worktree', 'repair', newAbsPath], { cwd: gitRoot }));
690
964
  }
691
- if (lockChanged) {
692
- runWarmCommands(newAbsPath, cfg);
693
- }
694
- withLock(poolDir, (state) => ({
695
- state: (0, pool_state_1.updateWorktree)(state, entry.id, {
965
+ const currentBase = step('read-base-commit', () => git(['rev-parse', cfg.base_ref], { cwd: gitRoot }));
966
+ step('warm-commands', () => {
967
+ if (lockfileChangedSince(gitRoot, entry.base_commit, newAbsPath, cfg)) {
968
+ runWarmCommands(newAbsPath, cfg);
969
+ }
970
+ });
971
+ step('commit-state', () => withLock(poolDir, (state) => ({
972
+ state: (0, pool_state_1.updateWorktree)(state, liveId, {
696
973
  id: newId,
697
974
  path: newId,
698
975
  status: 'warm',
@@ -701,35 +978,45 @@ function returnWorktree(worktreePath) {
701
978
  warmed_at: new Date().toISOString(),
702
979
  assigned_to_issue: null,
703
980
  assigned_branch: null,
981
+ broken_step: undefined,
982
+ broken_reason: undefined,
983
+ broken_branch: undefined,
704
984
  }),
705
985
  result: undefined,
706
- }));
986
+ })));
987
+ // `commit-state` renamed the row. Anything that fails from here on must
988
+ // mark THIS id, or the entry silently stays `warm` and claimable (#453).
989
+ liveId = newId;
990
+ // AC2: verify against reality before claiming success. A pool that only
991
+ // *says* it is warm is what #453 is about.
992
+ return {
993
+ id: newId,
994
+ path: newAbsPath,
995
+ verification: step('verify', () => verifyRecycled(poolDir, newId, newAbsPath, newTempBranch)),
996
+ };
707
997
  }
708
998
  catch (err) {
709
- // Cleanup is best-effort but never indiscriminate: `destroyWorktree`
710
- // refuses any path it cannot prove the pool created, and those refusals
711
- // are surfaced instead of being retried with `rm -rf`.
712
- const skipped = [];
713
- const ctx = buildOwnershipContext(gitRoot, poolDir);
714
- for (const [branch, target] of [
715
- [entry.temp_branch, absPath],
716
- [newTempBranch, newAbsPath],
717
- ]) {
718
- if (!fs.existsSync(target))
719
- continue;
720
- try {
721
- destroyWorktree(ctx, branch, target);
722
- }
723
- catch (cleanupErr) {
724
- skipped.push(cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr));
725
- }
726
- }
727
- withLock(poolDir, (state) => ({
728
- state: (0, pool_state_1.removeWorktree)(state, entry.id),
729
- result: undefined,
730
- }));
731
- const suffix = skipped.length > 0 ? ` Left on disk: ${skipped.join('; ')}` : '';
732
- throw new Error(`Recycle failed (destroyed worktree): ${err instanceof Error ? err.message : String(err)}.${suffix}`);
999
+ const failedStep = err instanceof StepError ? err.step : 'unknown';
1000
+ const cause = err instanceof StepError ? (err.cause ?? err) : err;
1001
+ const reason = sanitizeReason(messageOf(cause));
1002
+ // Mark broken instead of destroying (#453). The directory stays on disk so
1003
+ // the failure can be inspected; the entry points at where the directory
1004
+ // actually is so `classifyPoolDirEntry` still calls it ours and `gc` can
1005
+ // clear it.
1006
+ const onDiskPath = fs.existsSync(livePath) ? livePath : absPath;
1007
+ const observedBranch = currentBranchOf(onDiskPath);
1008
+ const markError = markEntryBroken(poolDir, liveId, {
1009
+ status: 'broken',
1010
+ path: relativePoolPath(poolDir, onDiskPath, entry.path),
1011
+ // NEVER widen provenance from what happens to be checked out: recording
1012
+ // a developer's branch here would make `classifyPoolDirEntry`'s ownership
1013
+ // test tautological and hand `gc` a directory it must refuse (#438).
1014
+ temp_branch: (0, pool_state_1.isPoolTempBranch)(observedBranch) ? observedBranch : liveTempBranch,
1015
+ broken_step: failedStep,
1016
+ broken_reason: reason,
1017
+ broken_branch: observedBranch,
1018
+ });
1019
+ throw new ReturnFailure(failedStep, onDiskPath, liveId, cause, markError);
733
1020
  }
734
1021
  }
735
1022
  function refresh() {
@@ -798,6 +1085,7 @@ async function gc(opts = {}) {
798
1085
  const errors = [];
799
1086
  const staleIds = [];
800
1087
  const orphanIds = [];
1088
+ const brokenIds = [];
801
1089
  let removed = 0;
802
1090
  const state = readState(poolDir);
803
1091
  const ctx = buildOwnershipContext(gitRoot, poolDir, state);
@@ -806,8 +1094,32 @@ async function gc(opts = {}) {
806
1094
  const ownedNames = new Set(onDisk.filter((e) => e.owned).map((e) => e.name));
807
1095
  const candidates = [];
808
1096
  const claimed = new Set();
1097
+ // Entries a `return` left broken (#453). Collected immediately rather than
1098
+ // after `stale_after_hours`: they are unusable from the moment they are
1099
+ // marked, they occupy `max_pool_size` capacity until removed, and the CLI
1100
+ // tells the operator to clear them with `gc`. Still routed through
1101
+ // `classifyPath`, the removal plan, and the --yes/TTY confirmation.
1102
+ for (const wt of state.worktrees) {
1103
+ if (wt.status !== 'broken')
1104
+ continue;
1105
+ claimed.add(wt.id);
1106
+ const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
1107
+ const why = `return failed at '${wt.broken_step ?? 'unknown'}': ` +
1108
+ `${(wt.broken_reason ?? 'unknown').split('\n')[0]}`;
1109
+ candidates.push({
1110
+ kind: classification.owned ? 'broken' : 'orphan-state',
1111
+ id: wt.id,
1112
+ path: classification.owned ? toAbs(poolDir, wt.path) : null,
1113
+ tempBranch: wt.temp_branch,
1114
+ reason: classification.owned
1115
+ ? `${why}; ${classification.reason}`
1116
+ : `${why}; directory is ${classification.reason} — directory left on disk`,
1117
+ });
1118
+ }
809
1119
  // Stale pool worktrees.
810
1120
  for (const wt of (0, pool_state_1.findStaleWorktrees)(state)) {
1121
+ if (claimed.has(wt.id))
1122
+ continue;
811
1123
  claimed.add(wt.id);
812
1124
  const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
813
1125
  if (classification.owned) {
@@ -861,7 +1173,7 @@ async function gc(opts = {}) {
861
1173
  });
862
1174
  }
863
1175
  describeGcPlan(candidates, foreign);
864
- const base = { staleIds, orphanIds, foreign, candidates, errors };
1176
+ const base = { staleIds, orphanIds, brokenIds, foreign, candidates, errors };
865
1177
  if (opts.dryRun) {
866
1178
  console.error('\nDry run — nothing was removed.');
867
1179
  return { ...base, removed: 0, dryRun: true, aborted: false };
@@ -894,6 +1206,8 @@ async function gc(opts = {}) {
894
1206
  }
895
1207
  if (c.kind === 'stale')
896
1208
  staleIds.push(c.id);
1209
+ else if (c.kind === 'broken')
1210
+ brokenIds.push(c.id);
897
1211
  else
898
1212
  orphanIds.push(c.id);
899
1213
  removed++;
@@ -909,10 +1223,12 @@ function status() {
909
1223
  const gitRoot = findGitRoot();
910
1224
  const poolDir = resolvePoolDirSync(gitRoot);
911
1225
  const state = readState(poolDir);
1226
+ const { foreign, broken } = reportPoolDirEntries(gitRoot, poolDir, state);
912
1227
  return {
913
1228
  ...(0, pool_state_1.getPoolStatus)(state),
914
1229
  pool_dir: poolDir,
915
- foreign: foreignInPoolDir(gitRoot, poolDir, state),
1230
+ foreign,
1231
+ broken,
916
1232
  };
917
1233
  }
918
1234
  /**