@ai-dossier/worktree-pool 0.5.3 → 0.6.1

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,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ReturnFailure = exports.WARM_COMMAND_TIMEOUT_MS = void 0;
36
37
  exports.readPoolFileConfig = readPoolFileConfig;
37
38
  exports.resolvePoolDir = resolvePoolDir;
38
39
  exports.findBrokenEntries = findBrokenEntries;
@@ -53,7 +54,8 @@ const project_env_1 = require("./project-env");
53
54
  const types_1 = require("./types");
54
55
  const LOCK_TIMEOUT_MS = 10_000;
55
56
  const LOCK_RETRY_MS = 200;
56
- const WARM_COMMAND_TIMEOUT_MS = 300_000;
57
+ /** Per-command budget for a warm-up install/build (#561: also the floor `packages/sched`'s batch-setup warm step should give its own exec). */
58
+ exports.WARM_COMMAND_TIMEOUT_MS = 300_000;
57
59
  const POOL_CONFIG_FILE = '.worktree-pool.json';
58
60
  // --- Git helpers ---
59
61
  function findGitRoot() {
@@ -335,7 +337,7 @@ function runWarmCommands(worktreeAbsPath, cfg) {
335
337
  (0, node_child_process_1.execFileSync)(bin, args, {
336
338
  cwd: projectDir,
337
339
  stdio: 'pipe',
338
- timeout: WARM_COMMAND_TIMEOUT_MS,
340
+ timeout: exports.WARM_COMMAND_TIMEOUT_MS,
339
341
  });
340
342
  }
341
343
  }
@@ -347,6 +349,53 @@ function lockfilePathForWorktree(worktreeAbsPath, cfg) {
347
349
  const projectDir = (0, project_env_1.resolveProjectDir)(worktreeAbsPath, cfg.project_subdir);
348
350
  return (0, project_env_1.lockfilePathFor)((0, project_env_1.detectProjectEnv)(projectDir), cfg.project_subdir);
349
351
  }
352
+ /**
353
+ * Did the project lockfile change between `baseCommit` and the configured base
354
+ * ref? An unreadable diff counts as "changed" — re-warming is cheap, a stale
355
+ * `node_modules` is not.
356
+ */
357
+ function lockfileChangedSince(gitRoot, baseCommit, worktreeAbsPath, cfg) {
358
+ try {
359
+ const diff = git(['diff', '--name-only', `${baseCommit}..${cfg.base_ref}`], { cwd: gitRoot });
360
+ return (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePathForWorktree(worktreeAbsPath, cfg));
361
+ }
362
+ catch {
363
+ return true;
364
+ }
365
+ }
366
+ /** Message of a thrown value, whatever it is. */
367
+ function messageOf(err) {
368
+ return err instanceof Error ? err.message : String(err);
369
+ }
370
+ /**
371
+ * Make git's error text safe to persist and re-print. `.pool-state.json` is a
372
+ * plain file that `status` echoes back, and git quotes remote URLs verbatim in
373
+ * its errors — including any `https://user:token@host` credentials — so an
374
+ * unfiltered message would write a secret to disk and replay terminal escape
375
+ * sequences from a remote on every later `status`.
376
+ */
377
+ function sanitizeReason(text) {
378
+ return (text
379
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, '$1<redacted>@')
380
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escapes is the point
381
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
382
+ .slice(0, 2000));
383
+ }
384
+ /**
385
+ * The value to record as an entry's `path`. State paths are contractually bare
386
+ * directory names directly inside the pool directory (`classifyPath` matches on
387
+ * `basename`), so anything that escapes — from a hand-edited or corrupted state
388
+ * file — keeps the previously recorded name rather than being written back.
389
+ */
390
+ function relativePoolPath(poolDir, onDiskPath, fallback) {
391
+ const rel = path.relative(poolDir, onDiskPath);
392
+ const isBareSegment = rel.length > 0 &&
393
+ !path.isAbsolute(rel) &&
394
+ !rel.startsWith('..') &&
395
+ !rel.includes(path.sep) &&
396
+ rel !== '.';
397
+ return isBareSegment ? rel : fallback;
398
+ }
350
399
  // --- Ownership / provenance ---
351
400
  //
352
401
  // The pool directory is routinely shared with worktrees a developer made by
@@ -664,17 +713,7 @@ function claim(issue, branch) {
664
713
  // Check freshness
665
714
  const currentBase = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
666
715
  if (worktree.base_commit !== currentBase) {
667
- const lockfilePath = lockfilePathForWorktree(absPath, cfg);
668
- let lockChanged = false;
669
- try {
670
- const diff = git(['diff', '--name-only', `${worktree.base_commit}..${cfg.base_ref}`], {
671
- cwd: gitRoot,
672
- });
673
- lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
674
- }
675
- catch {
676
- lockChanged = true;
677
- }
716
+ const lockChanged = lockfileChangedSince(gitRoot, worktree.base_commit, absPath, cfg);
678
717
  git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: absPath });
679
718
  git(['reset', '--hard', cfg.base_ref], { cwd: absPath });
680
719
  if (lockChanged) {
@@ -724,6 +763,139 @@ function claim(issue, branch) {
724
763
  throw err;
725
764
  }
726
765
  }
766
+ /**
767
+ * A `return` that failed, tagged with the step that failed (#453 AC1). The
768
+ * pool entry has been left in `status: 'broken'` — not `assigned`, not `warm`
769
+ * — and the worktree directory is deliberately NOT destroyed, so the failure
770
+ * is inspectable and `gc` can clear it deliberately.
771
+ *
772
+ * `markError` is the one case where that promise could not be kept: the
773
+ * broken-marking write itself failed. Callers must report it rather than
774
+ * assume the entry is marked, or they reproduce the very bug #453 is about.
775
+ */
776
+ class ReturnFailure extends Error {
777
+ step;
778
+ entryId;
779
+ worktreePath;
780
+ /** Why the entry could NOT be marked broken, or `null` when it was marked. */
781
+ markError;
782
+ constructor(step, worktreePath, entryId, cause, markError = null) {
783
+ super(`return failed at step '${step}': ${messageOf(cause)}`, { cause });
784
+ this.name = 'ReturnFailure';
785
+ this.step = step;
786
+ this.entryId = entryId;
787
+ this.worktreePath = worktreePath;
788
+ this.markError = markError;
789
+ }
790
+ }
791
+ exports.ReturnFailure = ReturnFailure;
792
+ /** Run `fn`, tagging any throw with the step it happened in. */
793
+ function step(name, fn) {
794
+ try {
795
+ return fn();
796
+ }
797
+ catch (err) {
798
+ // Kept so that if a step is ever nested inside another, the innermost
799
+ // attribution wins rather than being overwritten by its caller.
800
+ if (err instanceof StepError)
801
+ throw err;
802
+ throw new StepError(name, err);
803
+ }
804
+ }
805
+ /** Internal carrier so `step` can attribute a failure without unwinding twice. */
806
+ class StepError extends Error {
807
+ step;
808
+ constructor(stepName, cause) {
809
+ super(messageOf(cause), { cause });
810
+ this.name = 'StepError';
811
+ this.step = stepName;
812
+ }
813
+ }
814
+ /** Branch checked out at `absPath`, or `null` when it cannot be determined. */
815
+ function currentBranchOf(absPath) {
816
+ try {
817
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: absPath });
818
+ return branch === 'HEAD' ? null : branch;
819
+ }
820
+ catch {
821
+ return null;
822
+ }
823
+ }
824
+ /**
825
+ * Tracked-file cleanliness of `absPath`. A git failure is reported as an error
826
+ * rather than as "dirty" — telling an operator to look for uncommitted changes
827
+ * that do not exist is the wrong place to send them.
828
+ */
829
+ function inspectWorktreeCleanliness(absPath) {
830
+ let output;
831
+ try {
832
+ output = git(['status', '--porcelain', '--untracked-files=no'], { cwd: absPath });
833
+ }
834
+ catch (err) {
835
+ return { clean: false, entries: [`git status failed: ${messageOf(err)}`] };
836
+ }
837
+ const entries = output.length === 0 ? [] : output.split('\n');
838
+ return { clean: entries.length === 0, entries };
839
+ }
840
+ /**
841
+ * Verify a finished recycle against reality (#453 AC2). Separate from
842
+ * `returnWorktree` so the rules are readable and testable on their own.
843
+ */
844
+ function verifyRecycled(poolDir, newId, newAbsPath, newTempBranch) {
845
+ const committed = readState(poolDir).worktrees.find((w) => w.id === newId);
846
+ const cleanliness = inspectWorktreeCleanliness(newAbsPath);
847
+ const result = {
848
+ entry_status: committed?.status ?? 'missing',
849
+ directory_clean: cleanliness.clean,
850
+ dirty_entries: cleanliness.entries,
851
+ checked_out_branch: currentBranchOf(newAbsPath),
852
+ expected_branch: newTempBranch,
853
+ };
854
+ const problems = [];
855
+ if (result.entry_status !== 'warm') {
856
+ problems.push(`entry status is '${result.entry_status}', expected 'warm'`);
857
+ }
858
+ if (!result.directory_clean) {
859
+ problems.push(`directory ${newAbsPath} is not clean: ${result.dirty_entries.slice(0, 5).join(', ')}`);
860
+ }
861
+ if (result.checked_out_branch !== newTempBranch) {
862
+ problems.push(`checked-out branch is '${result.checked_out_branch ?? 'detached HEAD'}', ` +
863
+ `expected '${newTempBranch}'`);
864
+ }
865
+ if (problems.length > 0) {
866
+ throw new Error(`post-return self-check failed: ${problems.join('; ')}`);
867
+ }
868
+ return result;
869
+ }
870
+ /**
871
+ * Mark a pool entry `broken` after a failed recycle. Returns `null` on
872
+ * success, or the reason the mark itself failed — never throws, because the
873
+ * caller is already reporting a failure and must not lose it, but never
874
+ * silently either: an unmarked entry is the #453 bug.
875
+ */
876
+ function markEntryBroken(poolDir, entryId, updates) {
877
+ try {
878
+ withLock(poolDir, (state) => ({
879
+ state: (0, pool_state_1.updateWorktree)(state, entryId, updates),
880
+ result: undefined,
881
+ }));
882
+ return null;
883
+ }
884
+ catch (err) {
885
+ return messageOf(err);
886
+ }
887
+ }
888
+ /**
889
+ * Recycle an assigned worktree back to `warm`.
890
+ *
891
+ * Transactional in outcome (#453): it either finishes and returns a verified
892
+ * `warm` entry, or it leaves the entry `broken` and throws a `ReturnFailure`
893
+ * naming the step. It never leaves the entry `assigned`, never reports success
894
+ * over an unverified pool, and — unlike the pre-#453 behaviour — never
895
+ * destroys the worktree on failure. A broken entry keeps its provenance
896
+ * (recorded id, actual on-disk path, a pool-owned temp branch) so `status`
897
+ * shows it and `gc` can remove it.
898
+ */
727
899
  function returnWorktree(worktreePath) {
728
900
  const gitRoot = findGitRoot();
729
901
  const cfg = readPoolFileConfig(gitRoot);
@@ -732,55 +904,73 @@ function returnWorktree(worktreePath) {
732
904
  const newId = (0, types_1.generateId)();
733
905
  const newTempBranch = `pool/spare-${newId}`;
734
906
  const newAbsPath = toAbs(poolDir, newId);
735
- // Look up entry and mark as recycling atomically under lock
736
- const entry = withLock(poolDir, (state) => {
737
- const found = state.worktrees.find((w) => toAbs(poolDir, w.path) === absPath);
738
- if (!found) {
739
- throw new Error(`Worktree not found in pool state: ${worktreePath}`);
740
- }
741
- return {
742
- state: (0, pool_state_1.updateWorktree)(state, found.id, { status: 'recycling' }),
743
- result: { ...found },
744
- };
745
- });
907
+ // Look up the entry and mark it `recycling`, atomically. This is the one
908
+ // failure with no entry to mark broken — there is no entry — but it still
909
+ // speaks the ReturnFailure contract so the CLI and library callers get the
910
+ // step name rather than a bare internal error.
911
+ let entry;
912
+ try {
913
+ entry = withLock(poolDir, (state) => {
914
+ const found = state.worktrees.find((w) => toAbs(poolDir, w.path) === absPath);
915
+ if (!found) {
916
+ throw new Error(`Worktree not found in pool state: ${worktreePath} (resolved to ${absPath}); ` +
917
+ `pool dir ${poolDir} tracks ${state.worktrees.length} entries`);
918
+ }
919
+ return {
920
+ state: (0, pool_state_1.updateWorktree)(state, found.id, { status: 'recycling' }),
921
+ result: { ...found },
922
+ };
923
+ });
924
+ }
925
+ catch (err) {
926
+ throw new ReturnFailure('lookup', absPath, null, err);
927
+ }
928
+ // Where the directory, its branch and its state row actually are right now.
929
+ // Updated as the steps progress so a failure after a partial rename — or
930
+ // after `commit-state` has already renamed the entry id — records the truth
931
+ // rather than the values we started from.
932
+ let livePath = absPath;
933
+ let liveTempBranch = entry.temp_branch;
934
+ let liveId = entry.id;
746
935
  try {
747
- git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: absPath });
748
- git(['checkout', '-b', newTempBranch, cfg.base_ref], { cwd: absPath });
749
- git(['clean', '-fd'], { cwd: absPath });
750
- if (entry.assigned_branch) {
936
+ step('fetch', () => git(['fetch', (0, pool_state_1.remoteForBaseRef)(cfg.base_ref)], { cwd: livePath }));
937
+ step('checkout-temp-branch', () => git(['checkout', '-b', newTempBranch, cfg.base_ref], { cwd: livePath }));
938
+ liveTempBranch = newTempBranch;
939
+ step('clean', () => git(['clean', '-fd'], { cwd: livePath }));
940
+ const assignedBranch = entry.assigned_branch;
941
+ if (assignedBranch) {
942
+ // Best-effort: the branch may already be gone. Anything else is worth
943
+ // saying out loud — a surviving branch collides on the next claim.
751
944
  try {
752
- git(['branch', '-D', entry.assigned_branch], { cwd: absPath });
945
+ git(['branch', '-D', assignedBranch], { cwd: livePath });
753
946
  }
754
- catch {
755
- // may not exist
947
+ catch (err) {
948
+ const msg = messageOf(err);
949
+ if (!/not found|no branch named/i.test(msg)) {
950
+ console.error(`Warning: could not delete assigned branch '${assignedBranch}': ${msg}`);
951
+ }
756
952
  }
757
953
  }
758
- if (absPath !== newAbsPath) {
759
- if (fs.existsSync(newAbsPath)) {
760
- throw new Error(`Recycle target already exists: ${newAbsPath}`);
761
- }
762
- fs.renameSync(absPath, newAbsPath);
954
+ if (livePath !== newAbsPath) {
955
+ step('rename', () => {
956
+ if (fs.existsSync(newAbsPath)) {
957
+ throw new Error(`Recycle target already exists: ${newAbsPath}`);
958
+ }
959
+ fs.renameSync(livePath, newAbsPath);
960
+ });
961
+ livePath = newAbsPath;
763
962
  // Repair the moved worktree by its new path — see the note in `claim`;
764
963
  // a pathless repair leaves a dangling gitdir for `prune` to delete (#443).
765
- git(['worktree', 'repair', newAbsPath], { cwd: gitRoot });
766
- }
767
- const currentBase = git(['rev-parse', cfg.base_ref], { cwd: gitRoot });
768
- const lockfilePath = lockfilePathForWorktree(newAbsPath, cfg);
769
- let lockChanged = false;
770
- try {
771
- const diff = git(['diff', '--name-only', `${entry.base_commit}..${cfg.base_ref}`], {
772
- cwd: gitRoot,
773
- });
774
- lockChanged = (0, project_env_1.lockfileChangedInDiff)(diff, lockfilePath);
964
+ step('repair', () => git(['worktree', 'repair', newAbsPath], { cwd: gitRoot }));
775
965
  }
776
- catch {
777
- lockChanged = true;
778
- }
779
- if (lockChanged) {
780
- runWarmCommands(newAbsPath, cfg);
781
- }
782
- withLock(poolDir, (state) => ({
783
- state: (0, pool_state_1.updateWorktree)(state, entry.id, {
966
+ const currentBase = step('read-base-commit', () => git(['rev-parse', cfg.base_ref], { cwd: gitRoot }));
967
+ step('warm-commands', () => {
968
+ if (lockfileChangedSince(gitRoot, entry.base_commit, newAbsPath, cfg)) {
969
+ runWarmCommands(newAbsPath, cfg);
970
+ }
971
+ });
972
+ step('commit-state', () => withLock(poolDir, (state) => ({
973
+ state: (0, pool_state_1.updateWorktree)(state, liveId, {
784
974
  id: newId,
785
975
  path: newId,
786
976
  status: 'warm',
@@ -789,35 +979,45 @@ function returnWorktree(worktreePath) {
789
979
  warmed_at: new Date().toISOString(),
790
980
  assigned_to_issue: null,
791
981
  assigned_branch: null,
982
+ broken_step: undefined,
983
+ broken_reason: undefined,
984
+ broken_branch: undefined,
792
985
  }),
793
986
  result: undefined,
794
- }));
987
+ })));
988
+ // `commit-state` renamed the row. Anything that fails from here on must
989
+ // mark THIS id, or the entry silently stays `warm` and claimable (#453).
990
+ liveId = newId;
991
+ // AC2: verify against reality before claiming success. A pool that only
992
+ // *says* it is warm is what #453 is about.
993
+ return {
994
+ id: newId,
995
+ path: newAbsPath,
996
+ verification: step('verify', () => verifyRecycled(poolDir, newId, newAbsPath, newTempBranch)),
997
+ };
795
998
  }
796
999
  catch (err) {
797
- // Cleanup is best-effort but never indiscriminate: `destroyWorktree`
798
- // refuses any path it cannot prove the pool created, and those refusals
799
- // are surfaced instead of being retried with `rm -rf`.
800
- const skipped = [];
801
- const ctx = buildOwnershipContext(gitRoot, poolDir);
802
- for (const [branch, target] of [
803
- [entry.temp_branch, absPath],
804
- [newTempBranch, newAbsPath],
805
- ]) {
806
- if (!fs.existsSync(target))
807
- continue;
808
- try {
809
- destroyWorktree(ctx, branch, target);
810
- }
811
- catch (cleanupErr) {
812
- skipped.push(cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr));
813
- }
814
- }
815
- withLock(poolDir, (state) => ({
816
- state: (0, pool_state_1.removeWorktree)(state, entry.id),
817
- result: undefined,
818
- }));
819
- const suffix = skipped.length > 0 ? ` Left on disk: ${skipped.join('; ')}` : '';
820
- throw new Error(`Recycle failed (destroyed worktree): ${err instanceof Error ? err.message : String(err)}.${suffix}`);
1000
+ const failedStep = err instanceof StepError ? err.step : 'unknown';
1001
+ const cause = err instanceof StepError ? (err.cause ?? err) : err;
1002
+ const reason = sanitizeReason(messageOf(cause));
1003
+ // Mark broken instead of destroying (#453). The directory stays on disk so
1004
+ // the failure can be inspected; the entry points at where the directory
1005
+ // actually is so `classifyPoolDirEntry` still calls it ours and `gc` can
1006
+ // clear it.
1007
+ const onDiskPath = fs.existsSync(livePath) ? livePath : absPath;
1008
+ const observedBranch = currentBranchOf(onDiskPath);
1009
+ const markError = markEntryBroken(poolDir, liveId, {
1010
+ status: 'broken',
1011
+ path: relativePoolPath(poolDir, onDiskPath, entry.path),
1012
+ // NEVER widen provenance from what happens to be checked out: recording
1013
+ // a developer's branch here would make `classifyPoolDirEntry`'s ownership
1014
+ // test tautological and hand `gc` a directory it must refuse (#438).
1015
+ temp_branch: (0, pool_state_1.isPoolTempBranch)(observedBranch) ? observedBranch : liveTempBranch,
1016
+ broken_step: failedStep,
1017
+ broken_reason: reason,
1018
+ broken_branch: observedBranch,
1019
+ });
1020
+ throw new ReturnFailure(failedStep, onDiskPath, liveId, cause, markError);
821
1021
  }
822
1022
  }
823
1023
  function refresh() {
@@ -886,6 +1086,7 @@ async function gc(opts = {}) {
886
1086
  const errors = [];
887
1087
  const staleIds = [];
888
1088
  const orphanIds = [];
1089
+ const brokenIds = [];
889
1090
  let removed = 0;
890
1091
  const state = readState(poolDir);
891
1092
  const ctx = buildOwnershipContext(gitRoot, poolDir, state);
@@ -894,8 +1095,32 @@ async function gc(opts = {}) {
894
1095
  const ownedNames = new Set(onDisk.filter((e) => e.owned).map((e) => e.name));
895
1096
  const candidates = [];
896
1097
  const claimed = new Set();
1098
+ // Entries a `return` left broken (#453). Collected immediately rather than
1099
+ // after `stale_after_hours`: they are unusable from the moment they are
1100
+ // marked, they occupy `max_pool_size` capacity until removed, and the CLI
1101
+ // tells the operator to clear them with `gc`. Still routed through
1102
+ // `classifyPath`, the removal plan, and the --yes/TTY confirmation.
1103
+ for (const wt of state.worktrees) {
1104
+ if (wt.status !== 'broken')
1105
+ continue;
1106
+ claimed.add(wt.id);
1107
+ const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
1108
+ const why = `return failed at '${wt.broken_step ?? 'unknown'}': ` +
1109
+ `${(wt.broken_reason ?? 'unknown').split('\n')[0]}`;
1110
+ candidates.push({
1111
+ kind: classification.owned ? 'broken' : 'orphan-state',
1112
+ id: wt.id,
1113
+ path: classification.owned ? toAbs(poolDir, wt.path) : null,
1114
+ tempBranch: wt.temp_branch,
1115
+ reason: classification.owned
1116
+ ? `${why}; ${classification.reason}`
1117
+ : `${why}; directory is ${classification.reason} — directory left on disk`,
1118
+ });
1119
+ }
897
1120
  // Stale pool worktrees.
898
1121
  for (const wt of (0, pool_state_1.findStaleWorktrees)(state)) {
1122
+ if (claimed.has(wt.id))
1123
+ continue;
899
1124
  claimed.add(wt.id);
900
1125
  const classification = classifyPath(ctx, toAbs(poolDir, wt.path));
901
1126
  if (classification.owned) {
@@ -949,7 +1174,7 @@ async function gc(opts = {}) {
949
1174
  });
950
1175
  }
951
1176
  describeGcPlan(candidates, foreign);
952
- const base = { staleIds, orphanIds, foreign, candidates, errors };
1177
+ const base = { staleIds, orphanIds, brokenIds, foreign, candidates, errors };
953
1178
  if (opts.dryRun) {
954
1179
  console.error('\nDry run — nothing was removed.');
955
1180
  return { ...base, removed: 0, dryRun: true, aborted: false };
@@ -982,6 +1207,8 @@ async function gc(opts = {}) {
982
1207
  }
983
1208
  if (c.kind === 'stale')
984
1209
  staleIds.push(c.id);
1210
+ else if (c.kind === 'broken')
1211
+ brokenIds.push(c.id);
985
1212
  else
986
1213
  orphanIds.push(c.id);
987
1214
  removed++;