@yemi33/minions 0.1.2424 → 0.1.2426

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.
Files changed (185) hide show
  1. package/bin/install-internal-minions.js +1209 -0
  2. package/bin/minions.js +75 -9
  3. package/dashboard/docs/typography.md +27 -12
  4. package/dashboard/js/command-center.js +13 -11
  5. package/dashboard/js/live-stream.js +1 -1
  6. package/dashboard/js/memory-search.js +388 -43
  7. package/dashboard/js/modal-qa.js +5 -5
  8. package/dashboard/js/qa.js +39 -26
  9. package/dashboard/js/refresh.js +37 -18
  10. package/dashboard/js/render-agents.js +14 -18
  11. package/dashboard/js/render-dispatch.js +14 -74
  12. package/dashboard/js/render-inbox.js +3 -3
  13. package/dashboard/js/render-meetings.js +8 -8
  14. package/dashboard/js/render-other.js +45 -20
  15. package/dashboard/js/render-pipelines.js +6 -6
  16. package/dashboard/js/render-plans.js +14 -14
  17. package/dashboard/js/render-prd.js +46 -46
  18. package/dashboard/js/render-prs.js +9 -65
  19. package/dashboard/js/render-schedules.js +5 -5
  20. package/dashboard/js/render-skills.js +6 -6
  21. package/dashboard/js/render-utils.js +4 -4
  22. package/dashboard/js/render-watches.js +4 -16
  23. package/dashboard/js/render-work-items.js +95 -107
  24. package/dashboard/js/settings.js +175 -74
  25. package/dashboard/layout.html +3 -2
  26. package/dashboard/pages/inbox.html +1 -1
  27. package/dashboard/pages/work.html +1 -1
  28. package/dashboard/shared/model-display.js +9 -0
  29. package/dashboard/shared/watches-source.js +43 -0
  30. package/dashboard/shared/welcome-popup.js +259 -0
  31. package/dashboard/slim/body.html +6 -6
  32. package/dashboard/slim/js/chat.js +4 -2
  33. package/dashboard/slim/js/history.js +10 -3
  34. package/dashboard/slim/js/modals-tiles.js +85 -16
  35. package/dashboard/slim/js/status.js +12 -16
  36. package/dashboard/slim/styles.css +24 -4
  37. package/dashboard/styles.css +62 -27
  38. package/dashboard-build.js +8 -2
  39. package/dashboard.js +3854 -2514
  40. package/docs/README.md +7 -5
  41. package/docs/api-errors.md +144 -0
  42. package/docs/auto-discovery.md +83 -61
  43. package/docs/capture-demos.js +173 -30
  44. package/docs/command-center.md +5 -1
  45. package/docs/completion-reports.md +127 -11
  46. package/docs/constants.md +15 -3
  47. package/docs/constellation-style-telemetry.md +2 -2
  48. package/docs/copilot-cli-schema.md +32 -22
  49. package/docs/cross-repo-plans.md +30 -30
  50. package/docs/demo/memory-system.html +1 -1
  51. package/docs/deprecated.json +132 -18
  52. package/docs/diagnostics-memory.md +8 -4
  53. package/docs/human-vs-automated.md +2 -2
  54. package/docs/index.html +9 -2
  55. package/docs/internal-install.md +212 -0
  56. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  57. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  58. package/docs/kb-sweep.md +2 -2
  59. package/docs/keep-processes.md +8 -1
  60. package/docs/live-checkout-mode.md +1 -1
  61. package/docs/managed-spawn.md +21 -3
  62. package/docs/named-agents.md +3 -2
  63. package/docs/onboarding.md +26 -0
  64. package/docs/plan-lifecycle.md +32 -32
  65. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-AFTER.png +0 -0
  66. package/docs/pr-screenshots/pr-899/worker-pool-worktrees-BEFORE.png +0 -0
  67. package/docs/pr-screenshots/pr-979/auto-fix-pane-AFTER.png +0 -0
  68. package/docs/pr-screenshots/pr-979/auto-fix-pane-BEFORE.png +0 -0
  69. package/docs/pr-screenshots/pr-985/pr-column-em-dash-AFTER.png +0 -0
  70. package/docs/pr-screenshots/pr-985/pr-column-em-dash-BEFORE.png +0 -0
  71. package/docs/qa-runbook-lifecycle.md +67 -51
  72. package/docs/qa-runbooks.md +2 -2
  73. package/docs/runtime-adapters.md +101 -50
  74. package/docs/security.md +28 -0
  75. package/docs/self-improvement.md +47 -13
  76. package/docs/skills.md +29 -0
  77. package/docs/slim-ux/concepts.md +2 -2
  78. package/docs/specs/agent-configurability.md +43 -41
  79. package/docs/specs/agent-rename.md +13 -13
  80. package/docs/team-memory.md +283 -14
  81. package/docs/tutorials/01-install-and-connect.md +23 -0
  82. package/docs/tutorials/03-plan-driven-feature.md +3 -3
  83. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  84. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  85. package/docs/tutorials/README.md +4 -3
  86. package/docs/visual-evidence-ci.md +103 -0
  87. package/docs/watches.md +35 -30
  88. package/docs/worktree-lifecycle.md +151 -13
  89. package/engine/acp-transport.js +1 -0
  90. package/engine/ado.js +80 -33
  91. package/engine/agent-api-validation.js +571 -0
  92. package/engine/agent-worker-pool.js +7 -6
  93. package/engine/api-contracts/agent-content.js +761 -0
  94. package/engine/api-contracts/cc-ops.js +219 -0
  95. package/engine/api-contracts/config-runtime.js +419 -0
  96. package/engine/api-contracts/core.js +138 -0
  97. package/engine/api-contracts/index.js +506 -0
  98. package/engine/api-contracts/orchestration.js +602 -0
  99. package/engine/api-contracts/pull-requests.js +323 -0
  100. package/engine/api-contracts/qa-process.js +764 -0
  101. package/engine/api-contracts/work-plan-prd.js +494 -0
  102. package/engine/api-validation.js +543 -0
  103. package/engine/cc-api-validation.js +586 -0
  104. package/engine/cc-worker-pool.js +9 -1
  105. package/engine/cleanup.js +393 -271
  106. package/engine/cli.js +65 -7
  107. package/engine/comment-classifier.js +70 -5
  108. package/engine/consolidation.js +123 -5
  109. package/engine/content-api-validation.js +606 -0
  110. package/engine/create-pr-worktree.js +22 -1
  111. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  112. package/engine/dispatch.js +33 -0
  113. package/engine/distribution.js +189 -0
  114. package/engine/execution-model.js +59 -0
  115. package/engine/features.js +12 -0
  116. package/engine/github.js +67 -25
  117. package/engine/harness-context.js +313 -0
  118. package/engine/inbox-store.js +1 -1
  119. package/engine/kb-sweep.js +161 -38
  120. package/engine/keep-process-sweep.js +43 -3
  121. package/engine/lifecycle.js +1066 -165
  122. package/engine/llm.js +4 -0
  123. package/engine/managed-spawn.js +463 -48
  124. package/engine/meeting.js +197 -34
  125. package/engine/memory-retrieval.js +280 -0
  126. package/engine/memory-store.js +821 -38
  127. package/engine/model-discovery.js +17 -3
  128. package/engine/pipeline.js +802 -14
  129. package/engine/plan-prd-validation.js +745 -0
  130. package/engine/playbook.js +206 -17
  131. package/engine/pooled-agent-process.js +6 -1
  132. package/engine/pr-action.js +1 -1
  133. package/engine/pr-issue-validation.js +653 -0
  134. package/engine/pr-resolve.js +75 -13
  135. package/engine/prd-store.js +39 -6
  136. package/engine/preflight.js +7 -2
  137. package/engine/process-utils.js +79 -31
  138. package/engine/projects.js +198 -12
  139. package/engine/promotion.js +371 -0
  140. package/engine/qa-from-prd.js +3 -0
  141. package/engine/qa-process-validation.js +572 -0
  142. package/engine/qa-runbooks.js +41 -37
  143. package/engine/qa-runners/maestro.js +11 -7
  144. package/engine/qa-runners/playwright.js +37 -14
  145. package/engine/qa-runners.js +9 -10
  146. package/engine/qa-runs.js +124 -11
  147. package/engine/qa-sessions.js +139 -40
  148. package/engine/quarantine-refs.js +81 -11
  149. package/engine/queries.js +163 -9
  150. package/engine/review-learning-backfill.js +482 -0
  151. package/engine/review-learning.js +1236 -0
  152. package/engine/runtimes/claude.js +60 -2
  153. package/engine/runtimes/codex.js +57 -16
  154. package/engine/runtimes/contract.js +21 -0
  155. package/engine/runtimes/copilot.js +51 -3
  156. package/engine/runtimes/index.js +1 -0
  157. package/engine/scheduler.js +201 -32
  158. package/engine/settings-validation.js +909 -0
  159. package/engine/shared.js +705 -208
  160. package/engine/spawn-agent.js +48 -21
  161. package/engine/steering-constraints.js +31 -0
  162. package/engine/steering-store.js +10 -2
  163. package/engine/steering.js +14 -2
  164. package/engine/timeout.js +404 -111
  165. package/engine/untrusted-fence.js +24 -10
  166. package/engine/watch-actions.js +7 -2
  167. package/engine/watches.js +353 -51
  168. package/engine/work-item-validation.js +561 -0
  169. package/engine/work-items-store.js +61 -1
  170. package/engine/worktree-gc.js +656 -37
  171. package/engine/worktree-preflight.js +154 -0
  172. package/engine.js +2068 -342
  173. package/package.json +1 -1
  174. package/playbooks/_pr-description-audit.md +88 -41
  175. package/playbooks/build-fix-complex.md +3 -3
  176. package/playbooks/docs.md +2 -2
  177. package/playbooks/fix.md +18 -18
  178. package/playbooks/implement.md +18 -18
  179. package/playbooks/qa-session-draft.md +10 -12
  180. package/playbooks/qa-session-execute.md +16 -12
  181. package/playbooks/qa-validate.md +8 -7
  182. package/playbooks/shared-rules.md +45 -0
  183. package/playbooks/test.md +4 -0
  184. package/prompts/cc-system.md +1 -1
  185. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
@@ -27,6 +27,9 @@ const path = require('path');
27
27
  const shared = require('./shared');
28
28
  const worktreePool = require('./worktree-pool');
29
29
 
30
+ const DEFAULT_ORPHAN_RETENTION_MS = 2 * 60 * 60 * 1000;
31
+ const DEFAULT_QUARANTINE_RETENTION_MS = 24 * 60 * 60 * 1000;
32
+
30
33
  let keepProcessSweep = null;
31
34
  function _keepProcessSweep() {
32
35
  if (!keepProcessSweep) keepProcessSweep = require('./keep-process-sweep');
@@ -41,20 +44,29 @@ function _managedSpawn() {
41
44
 
42
45
  const _noopLog = () => {};
43
46
 
44
- function _collectAnchoredCwds(listManagedSpecs, listKeepProcessCwds) {
47
+ function _pathIsLiveFailOpen(checkFn, worktreePath, opts) {
48
+ try { return !!checkFn(worktreePath, opts); }
49
+ catch { return true; }
50
+ }
51
+
52
+ function _collectAnchoredCwds(listManagedSpecs, listKeepProcessCwds, opts = {}) {
45
53
  const out = [];
46
54
  try {
47
55
  for (const rec of (listManagedSpecs() || [])) {
48
56
  if (!rec || typeof rec.cwd !== 'string' || !rec.cwd) continue;
49
57
  try { out.push(shared.realPathForComparison(rec.cwd)); } catch {}
50
58
  }
51
- } catch {}
59
+ } catch (err) {
60
+ if (opts.throwOnFailure) throw err;
61
+ }
52
62
  try {
53
63
  for (const cwd of (listKeepProcessCwds() || [])) {
54
64
  if (typeof cwd !== 'string' || !cwd) continue;
55
65
  try { out.push(shared.realPathForComparison(cwd)); } catch {}
56
66
  }
57
- } catch {}
67
+ } catch (err) {
68
+ if (opts.throwOnFailure) throw err;
69
+ }
58
70
  return out;
59
71
  }
60
72
 
@@ -67,6 +79,51 @@ function _isWorktreeCwdAnchored(worktreePath, anchoredCwds) {
67
79
  return false;
68
80
  }
69
81
 
82
+ function _isQuarantineDirName(name) {
83
+ return /-quarantine-(?:protected-)?\d+(?:$|-)/.test(String(name || ''));
84
+ }
85
+
86
+ function _quarantineCreatedAt(name, fallbackMs) {
87
+ const match = String(name || '').match(/-quarantine-(?:protected-)?(\d+)(?:$|-)/);
88
+ if (!match) return fallbackMs;
89
+ const parsed = Number(match[1]);
90
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackMs;
91
+ }
92
+
93
+ function isLegacyMinionsWorktreeDir(worktreePath, commonGitDir, opts = {}) {
94
+ const _fs = opts.fs || fs;
95
+ if (!worktreePath || !commonGitDir) return false;
96
+ const name = path.basename(path.resolve(worktreePath));
97
+ if (!/^W-[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) return false;
98
+ const dotGit = path.join(worktreePath, '.git');
99
+ let stat;
100
+ try { stat = _fs.lstatSync(dotGit); } catch { return false; }
101
+ if (!stat || !stat.isFile()) return false;
102
+ let raw;
103
+ try { raw = String(_fs.readFileSync(dotGit, 'utf8')); } catch { return false; }
104
+ const match = raw.match(/^gitdir:\s*(.+?)\s*$/im);
105
+ if (!match) return false;
106
+ let gitdir;
107
+ try {
108
+ gitdir = path.resolve(worktreePath, match[1].trim());
109
+ } catch {
110
+ return false;
111
+ }
112
+ const commonWorktrees = worktreePool._normalizePath(path.join(commonGitDir, 'worktrees'));
113
+ const candidate = worktreePool._normalizePath(gitdir);
114
+ return !!candidate && candidate.startsWith(commonWorktrees + '/');
115
+ }
116
+
117
+ function _resolveGitCommonDir(rootDir, execSilent) {
118
+ const raw = String(execSilent('git rev-parse --git-common-dir', {
119
+ cwd: rootDir,
120
+ timeout: 10000,
121
+ windowsHide: true,
122
+ }) || '').trim();
123
+ if (!raw) return null;
124
+ return path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(rootDir, raw);
125
+ }
126
+
70
127
  // ── Stuck-dir escalation (Layer 4 of W-mq5o6bvy000x7191) ─────────────────────
71
128
  // In-memory ring of paths that have failed removal N consecutive times. After
72
129
  // escalation we (a) write a dedup'd inbox note, (b) suppress repeat per-tick
@@ -333,7 +390,7 @@ function _resetStuckPathsForTesting() { _stuckPaths.clear(); }
333
390
  // OS to release the killed processes' file handles, then attempts ONE more
334
391
  // removal. On success, writes the recovered-via-holder-reap note via
335
392
  // `_markStuckSuccess` with the reaped-pid attribution.
336
- function _postReapRetry(wtPath, gitRoot, parentDir, resolvedPath, _removeWorktree, opts, projStats, result, log, reapedPids) {
393
+ function _postReapRetry(wtPath, gitRoot, parentDir, resolvedPath, _removeWorktree, opts, stats, log, reapedPids) {
337
394
  try { shared.clearWorktreeFailureCache(resolvedPath); } catch { /* optional */ }
338
395
 
339
396
  // The actual removal attempt, run after the settle window.
@@ -343,8 +400,12 @@ function _postReapRetry(wtPath, gitRoot, parentDir, resolvedPath, _removeWorktre
343
400
  if (removed) {
344
401
  // Down-count the failure we recorded just before; this dispatch
345
402
  // ultimately succeeded after the auto-reap.
346
- if (projStats) { projStats.failed = Math.max(0, projStats.failed - 1); projStats.evicted++; }
347
- if (result) { result.failed = Math.max(0, result.failed - 1); result.evicted++; }
403
+ for (const stat of stats) {
404
+ if (!stat) continue;
405
+ if (typeof stat.failed === 'number') stat.failed = Math.max(0, stat.failed - 1);
406
+ if (typeof stat.evicted === 'number') stat.evicted++;
407
+ if (typeof stat.removed === 'number') stat.removed++;
408
+ }
348
409
  _markStuckSuccess(resolvedPath, {
349
410
  writeToInbox: opts.writeToInbox,
350
411
  recoveryReason: 'holder-reap',
@@ -378,13 +439,16 @@ function _postReapRetry(wtPath, gitRoot, parentDir, resolvedPath, _removeWorktre
378
439
  try { settled = sleepFn(2000); } catch { /* sleep init failure — fire retry anyway */ }
379
440
 
380
441
  if (settled && typeof settled.then === 'function') {
381
- // Async settle window — fire-and-forget; never blocks the event loop.
382
- settled.then(() => { try { _doRetry(); } catch { /* ignore */ } }).catch(() => {});
383
- return;
442
+ // Async settle window — callers may observe completion without blocking
443
+ // the tick that scheduled it.
444
+ return Promise.resolve(settled)
445
+ .then(() => _doRetry(), () => false)
446
+ .catch(() => false);
384
447
  }
385
448
 
386
449
  // Sync settle window (test injection) — fire retry immediately.
387
450
  try { _doRetry(); } catch { /* ignore */ }
451
+ return null;
388
452
  }
389
453
 
390
454
  /**
@@ -424,7 +488,8 @@ function _postReapRetry(wtPath, gitRoot, parentDir, resolvedPath, _removeWorktre
424
488
  * matching gcDispatchWorktreeIfOrphan's existing contract. When `stats`
425
489
  * objects are supplied the helper mutates their evicted/failed/kept counters
426
490
  * directly (the sweeps); the dispatch-end caller passes none and maps the
427
- * return value instead.
491
+ * return value instead. Async post-reap completions are appended to an
492
+ * optional `deferredRetries` collector for aggregate callers.
428
493
  */
429
494
  function reapAndRemoveWorktree(opts) {
430
495
  const {
@@ -449,6 +514,7 @@ function reapAndRemoveWorktree(opts) {
449
514
  sleepSyncFn = null,
450
515
  // accounting + logging
451
516
  stats = [],
517
+ deferredRetries = null,
452
518
  successReason = 'orphan',
453
519
  successMetric = null,
454
520
  recheckSuppressedAfterEscalate = false,
@@ -472,6 +538,7 @@ function reapAndRemoveWorktree(opts) {
472
538
  if (typeof slowRetryMs === 'number') {
473
539
  if (!_shouldSlowRetry(resolved, slowRetryMs)) {
474
540
  _bumpStats('kept');
541
+ _bumpStats('skipped');
475
542
  return { ..._idle(), reason: 'slow-retry' };
476
543
  }
477
544
  }
@@ -483,6 +550,7 @@ function reapAndRemoveWorktree(opts) {
483
550
  catch (_e) { owned = false; }
484
551
  if (!owned) {
485
552
  _bumpStats('kept');
553
+ _bumpStats('preserved');
486
554
  if (typeof onMarkerSkipLog === 'function') log('debug', onMarkerSkipLog());
487
555
  return { ..._idle(), reason: 'no-marker' };
488
556
  }
@@ -512,12 +580,21 @@ function reapAndRemoveWorktree(opts) {
512
580
  return !escResult.alreadyEscalated && !suppressed;
513
581
  };
514
582
  const _postReapOpts = { writeToInbox, sleepSyncFn };
583
+ const _queuePostReapRetry = (reapedPids) => {
584
+ if (!enablePostReapRetry || reapedPids.length === 0) return;
585
+ const completion = _postReapRetry(
586
+ worktreePath, gitRoot, parentDir, resolved, _removeFn,
587
+ _postReapOpts, stats, log, reapedPids
588
+ );
589
+ if (completion && Array.isArray(deferredRetries)) deferredRetries.push(completion);
590
+ };
515
591
 
516
592
  // (4)+(5) remove + accounting. removeWorktree owns its own backoff loop.
517
593
  try {
518
594
  const removed = _removeFn(worktreePath, gitRoot, parentDir, rmOpts);
519
595
  if (removed) {
520
596
  _bumpStats('evicted');
597
+ _bumpStats('removed');
521
598
  _markStuckSuccess(resolved, { writeToInbox });
522
599
  if (successMetric) { try { shared.bumpWorktreeGcMetric(successMetric); } catch { /* optional */ } }
523
600
  if (typeof onRemovedLog === 'function') log('info', onRemovedLog());
@@ -527,18 +604,14 @@ function reapAndRemoveWorktree(opts) {
527
604
  const escResult = _maybeEscalateStuck(resolved, 'remove returned false', _escalateOpts);
528
605
  if (_shouldWarn(escResult) && typeof onFailLog === 'function') log('warn', onFailLog('false'));
529
606
  const reapedPids = escResult.reapedPids || [];
530
- if (enablePostReapRetry && reapedPids.length > 0) {
531
- _postReapRetry(worktreePath, gitRoot, parentDir, resolved, _removeFn, _postReapOpts, stats[0] || null, stats[1] || null, log, reapedPids);
532
- }
607
+ _queuePostReapRetry(reapedPids);
533
608
  return { outcome: 'gc-failed', removed: false, reason: 'remove-failed', escalated: escResult.escalated, stuckEscalated: escResult.escalated, reapedPids };
534
609
  } catch (rmErr) {
535
610
  _bumpStats('failed');
536
611
  const escResult = _maybeEscalateStuck(resolved, rmErr && rmErr.message, _escalateOpts);
537
612
  if (_shouldWarn(escResult) && typeof onFailLog === 'function') log('warn', onFailLog('threw', rmErr && rmErr.message));
538
613
  const reapedPids = escResult.reapedPids || [];
539
- if (enablePostReapRetry && reapedPids.length > 0) {
540
- _postReapRetry(worktreePath, gitRoot, parentDir, resolved, _removeFn, _postReapOpts, stats[0] || null, stats[1] || null, log, reapedPids);
541
- }
614
+ _queuePostReapRetry(reapedPids);
542
615
  return { outcome: 'gc-failed', removed: false, reason: 'remove-threw', escalated: escResult.escalated, stuckEscalated: escResult.escalated, reapedPids };
543
616
  }
544
617
  }
@@ -613,7 +686,10 @@ function shouldGcDispatchWorktree(opts) {
613
686
  return { gc: false, reason: 'managed-spawn-anchored' };
614
687
  }
615
688
 
616
- // keep_processes anchor check: any live PID for this agent skip.
689
+ // keep_processes anchor check: only live PIDs whose declared cwd is inside
690
+ // this worktree protect it. An agent can leave a process from an older
691
+ // dispatch alive, so an agent-wide PID match alone is not a path anchor.
692
+ // Legacy records without cwd remain fail-open and retain the worktree.
617
693
  if (agentId) {
618
694
  try {
619
695
  const fn = typeof getActiveAnchorPidsForAgent === 'function'
@@ -621,7 +697,19 @@ function shouldGcDispatchWorktree(opts) {
621
697
  : _keepProcessSweep().getActiveAnchorPidsForAgent;
622
698
  const res = fn(agentId);
623
699
  if (res && res.pids && res.pids.size > 0) {
624
- return { gc: false, reason: 'keep-processes-anchored' };
700
+ const anchorCwd = res.record && typeof res.record.cwd === 'string'
701
+ ? res.record.cwd
702
+ : '';
703
+ if (!anchorCwd) {
704
+ return { gc: false, reason: 'keep-processes-anchored' };
705
+ }
706
+ try {
707
+ if (shared.isPathInsideOrEqual(anchorCwd, worktreePath)) {
708
+ return { gc: false, reason: 'keep-processes-anchored' };
709
+ }
710
+ } catch (_e) {
711
+ return { gc: false, reason: 'keep-processes-anchored' };
712
+ }
625
713
  }
626
714
  } catch (_e) { /* sidecar reader optional */ }
627
715
  }
@@ -718,6 +806,404 @@ function gcDispatchWorktreeIfOrphan(opts) {
718
806
  return { outcome: 'gc-failed', reason: r.reason, removed: false, stuckEscalated: r.stuckEscalated };
719
807
  }
720
808
 
809
+ /**
810
+ * Reconcile each configured worktree root against Git's registry and remove
811
+ * stale on-disk residue that registry-derived GC cannot see. This pass is
812
+ * intentionally narrower than pruneOrphanWorktrees:
813
+ *
814
+ * - registered paths are not deleted here; only locked/initializing entries
815
+ * are protected from the downstream dispatch-oriented pruner;
816
+ * - active SQL claims, pool entries, process anchors, and cwd holders are
817
+ * preserved;
818
+ * - ownership requires the marker, or a legacy Windows W-* name whose .git
819
+ * pointer targets this project's common .git/worktrees directory;
820
+ * - ordinary residue waits two hours; quarantine residue waits 24 hours;
821
+ * - any Git registration-list failure skips the entire shared root.
822
+ */
823
+ function reconcileStaleWorktreeDirectories(opts) {
824
+ opts = opts || {};
825
+ const projects = Array.isArray(opts.projects) ? opts.projects : [];
826
+ const dispatchSnap = opts.dispatchSnap || { active: [], pending: [] };
827
+ const worktreeRootRel = typeof opts.worktreeRootRel === 'string' && opts.worktreeRootRel.length > 0
828
+ ? opts.worktreeRootRel
829
+ : (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.worktreeRoot) || '../worktrees';
830
+ const log = typeof opts.log === 'function' ? opts.log : _noopLog;
831
+ const _fs = opts.fs || fs;
832
+ const _execSilent = typeof opts.execSilent === 'function' ? opts.execSilent : shared.execSilent;
833
+ const _parseWorktreePorcelain = typeof opts.parseWorktreePorcelain === 'function'
834
+ ? opts.parseWorktreePorcelain
835
+ : shared.parseWorktreePorcelain;
836
+ const _removeWorktree = typeof opts.removeWorktree === 'function'
837
+ ? opts.removeWorktree
838
+ : shared.removeWorktree;
839
+ const _hasOwnerMarker = typeof opts.hasOwnerMarker === 'function'
840
+ ? opts.hasOwnerMarker
841
+ : shared.hasWorktreeOwnerMarker;
842
+ const _writeOwnerMarker = typeof opts.writeOwnerMarker === 'function'
843
+ ? opts.writeOwnerMarker
844
+ : shared.writeWorktreeOwnerMarker;
845
+ const _hasProtectedQuarantineMarker = typeof opts.hasProtectedQuarantineMarker === 'function'
846
+ ? opts.hasProtectedQuarantineMarker
847
+ : shared.hasProtectedQuarantineMarker;
848
+ const _isWorktreePathLive = typeof opts.isWorktreePathLive === 'function'
849
+ ? opts.isWorktreePathLive
850
+ : shared.isWorktreePathLive;
851
+ const _findProcessesWithCwdInside = typeof opts.findProcessesWithCwdInside === 'function'
852
+ ? opts.findProcessesWithCwdInside
853
+ : (process.platform === 'win32' && typeof shared.findProcessCwdHolders === 'function'
854
+ ? shared.findProcessCwdHolders
855
+ : shared.findProcessesWithCwdInside);
856
+ const _resolveCommonDir = typeof opts.resolveGitCommonDir === 'function'
857
+ ? opts.resolveGitCommonDir
858
+ : (rootDir) => _resolveGitCommonDir(rootDir, _execSilent);
859
+ const _parsedTreesByProject = opts.parsedTreesByProject instanceof Map
860
+ ? opts.parsedTreesByProject
861
+ : null;
862
+ const _liveOpts = opts.db ? { db: opts.db } : undefined;
863
+ const now = Number.isFinite(Number(opts.now)) ? Number(opts.now) : Date.now();
864
+ const orphanRetentionMs = Math.max(0,
865
+ Number.isFinite(Number(opts.orphanRetentionMs))
866
+ ? Number(opts.orphanRetentionMs)
867
+ : DEFAULT_ORPHAN_RETENTION_MS);
868
+ const quarantineRetentionMs = Math.max(0,
869
+ Number.isFinite(Number(opts.quarantineRetentionMs))
870
+ ? Number(opts.quarantineRetentionMs)
871
+ : DEFAULT_QUARANTINE_RETENTION_MS);
872
+
873
+ const result = {
874
+ scanned: 0,
875
+ removed: 0,
876
+ kept: 0,
877
+ failed: 0,
878
+ registered: 0,
879
+ skippedLive: 0,
880
+ pooled: 0,
881
+ anchored: 0,
882
+ processHeld: 0,
883
+ recent: 0,
884
+ foreign: 0,
885
+ protected: 0,
886
+ perRoot: {},
887
+ };
888
+ const protectedPaths = new Set();
889
+ const failedRoots = new Set();
890
+ Object.defineProperties(result, {
891
+ protectedPaths: { value: protectedPaths, enumerable: false },
892
+ failedRoots: { value: failedRoots, enumerable: false },
893
+ });
894
+
895
+ const groups = new Map();
896
+ for (const project of projects) {
897
+ if (!project || !project.localPath) continue;
898
+ let rootDir;
899
+ try { rootDir = path.resolve(String(project.localPath)); } catch { continue; }
900
+ let rootExists = false;
901
+ try { rootExists = _fs.existsSync(rootDir); } catch { rootExists = false; }
902
+ const wtParent = path.resolve(rootDir, worktreeRootRel);
903
+ let parentExists = false;
904
+ try { parentExists = _fs.existsSync(wtParent); } catch { parentExists = false; }
905
+ if (!parentExists) continue;
906
+ const key = worktreePool._normalizePath(wtParent);
907
+ if (!groups.has(key)) groups.set(key, { wtParent, projects: [] });
908
+ const group = groups.get(key);
909
+ if (!group.projects.some((entry) => entry.rootDir === rootDir)) {
910
+ group.projects.push({ project, rootDir, rootExists });
911
+ }
912
+ }
913
+
914
+ const globalLiveDirNames = new Set();
915
+ for (const dispatch of [...(dispatchSnap.active || []), ...(dispatchSnap.pending || [])]) {
916
+ if (!dispatch) continue;
917
+ if (dispatch.worktreePath) {
918
+ try { globalLiveDirNames.add(path.basename(path.resolve(dispatch.worktreePath))); } catch {}
919
+ }
920
+ if (!dispatch.id || !dispatch.meta?.branch) continue;
921
+ const projectName = typeof dispatch.meta.project === 'string'
922
+ ? dispatch.meta.project
923
+ : (dispatch.meta.project?.name || 'default');
924
+ try {
925
+ globalLiveDirNames.add(shared.buildWorktreeDirName({
926
+ dispatchId: dispatch.id,
927
+ projectName,
928
+ branchName: dispatch.meta.branch,
929
+ }));
930
+ } catch {}
931
+ }
932
+
933
+ const poolPaths = new Set();
934
+ let poolReadError = null;
935
+ try {
936
+ for (const entry of (worktreePool.readPool() || { entries: [] }).entries || []) {
937
+ if (entry?.path) poolPaths.add(worktreePool._normalizePath(entry.path));
938
+ }
939
+ } catch (err) {
940
+ poolReadError = err;
941
+ }
942
+ if (Array.isArray(opts.extraPoolPaths)) {
943
+ for (const candidate of opts.extraPoolPaths) {
944
+ poolPaths.add(worktreePool._normalizePath(candidate));
945
+ }
946
+ }
947
+ if (poolReadError) {
948
+ for (const group of groups.values()) {
949
+ failedRoots.add(worktreePool._normalizePath(group.wtParent));
950
+ }
951
+ result.failed = groups.size || 1;
952
+ log('warn', `worktree-reconcile: pool state unavailable (${poolReadError.message}) — fail-closed`);
953
+ return result;
954
+ }
955
+ const listManagedSpecs = typeof opts.listManagedSpecs === 'function'
956
+ ? opts.listManagedSpecs
957
+ : (() => _managedSpawn().listManagedSpecs());
958
+ const listKeepProcessCwds = typeof opts.listKeepProcessCwds === 'function'
959
+ ? opts.listKeepProcessCwds
960
+ : (() => _keepProcessSweep().getActiveKeepProcessCwds());
961
+ let anchoredCwds;
962
+ try {
963
+ anchoredCwds = _collectAnchoredCwds(
964
+ listManagedSpecs,
965
+ listKeepProcessCwds,
966
+ { throwOnFailure: true },
967
+ );
968
+ } catch (err) {
969
+ for (const group of groups.values()) {
970
+ failedRoots.add(worktreePool._normalizePath(group.wtParent));
971
+ }
972
+ result.failed = groups.size || 1;
973
+ log('warn', `worktree-reconcile: process anchor state unavailable (${err.message}) — fail-closed`);
974
+ return result;
975
+ }
976
+
977
+ for (const group of groups.values()) {
978
+ const stats = {
979
+ scanned: 0, removed: 0, kept: 0, failed: 0,
980
+ registered: 0, skippedLive: 0, pooled: 0, anchored: 0,
981
+ processHeld: 0, recent: 0, foreign: 0, protected: 0,
982
+ };
983
+ result.perRoot[group.wtParent] = stats;
984
+ const keep = (kind, candidate, protectDownstream = true) => {
985
+ if (kind) {
986
+ stats[kind]++;
987
+ result[kind]++;
988
+ }
989
+ stats.kept++;
990
+ result.kept++;
991
+ if (candidate && protectDownstream) protectedPaths.add(worktreePool._normalizePath(candidate));
992
+ };
993
+ const registeredPaths = new Set();
994
+ const lockedRegisteredPaths = new Set();
995
+ const commonGitDirs = [];
996
+ let registrationError = null;
997
+ let holderSnapshot = null;
998
+ let holderProbeError = null;
999
+ let holderProbeAttempted = false;
1000
+ const isProcessHeld = (candidate) => {
1001
+ if (!holderProbeAttempted) {
1002
+ holderProbeAttempted = true;
1003
+ try {
1004
+ holderSnapshot = _findProcessesWithCwdInside(group.wtParent, {
1005
+ timeoutMs: process.platform === 'win32'
1006
+ ? (opts.config?.engine?.statusProbeKillTimeoutMs
1007
+ ?? shared.ENGINE_DEFAULTS.statusProbeKillTimeoutMs)
1008
+ : (opts.config?.engine?.orphanHolderScanTimeoutMs
1009
+ ?? shared.ENGINE_DEFAULTS.orphanHolderScanTimeoutMs),
1010
+ throwOnFailure: true,
1011
+ }) || [];
1012
+ } catch (err) {
1013
+ holderProbeError = err;
1014
+ stats.failed++;
1015
+ result.failed++;
1016
+ log('warn', `worktree-reconcile: holder probe failed for ${group.wtParent}: ${err.message}`);
1017
+ }
1018
+ }
1019
+ if (holderProbeError) return true;
1020
+ return holderSnapshot.some((holder) => {
1021
+ if (!holder?.cwd) return true;
1022
+ try { return shared.isPathInsideOrEqual(holder.cwd, candidate); }
1023
+ catch { return true; }
1024
+ });
1025
+ };
1026
+
1027
+ for (const { project, rootDir, rootExists } of group.projects) {
1028
+ let trees;
1029
+ try {
1030
+ if (!rootExists) {
1031
+ throw new Error(`configured project localPath does not exist: ${rootDir}`);
1032
+ }
1033
+ if (_parsedTreesByProject && _parsedTreesByProject.has(rootDir)) {
1034
+ trees = _parsedTreesByProject.get(rootDir);
1035
+ } else {
1036
+ const raw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
1037
+ cwd: rootDir,
1038
+ timeout: 15000,
1039
+ windowsHide: true,
1040
+ }) || '');
1041
+ trees = _parseWorktreePorcelain(raw);
1042
+ }
1043
+ if (!Array.isArray(trees)) throw new Error('worktree registry parser did not return an array');
1044
+ for (const tree of trees) {
1045
+ if (!tree?.path) continue;
1046
+ const normalized = worktreePool._normalizePath(tree.path);
1047
+ registeredPaths.add(normalized);
1048
+ if (tree.locked) lockedRegisteredPaths.add(normalized);
1049
+ }
1050
+ } catch (err) {
1051
+ registrationError = err;
1052
+ log('warn', `worktree-reconcile: registry list failed for ${project.name || rootDir}: ${err.message}`);
1053
+ break;
1054
+ }
1055
+ try {
1056
+ const commonDir = _resolveCommonDir(rootDir, project);
1057
+ if (commonDir) commonGitDirs.push(path.resolve(commonDir));
1058
+ } catch (err) {
1059
+ log('debug', `worktree-reconcile: common git dir unavailable for ${project.name || rootDir}: ${err.message}`);
1060
+ }
1061
+ }
1062
+
1063
+ if (registrationError) {
1064
+ stats.failed++;
1065
+ result.failed++;
1066
+ failedRoots.add(worktreePool._normalizePath(group.wtParent));
1067
+ try { shared.bumpWorktreeGcMetric('reconcileFailures'); } catch {}
1068
+ if (typeof opts.writeToInbox === 'function') {
1069
+ try {
1070
+ const rootSlug = path.basename(group.wtParent).replace(/[^a-zA-Z0-9._-]+/g, '-');
1071
+ opts.writeToInbox('engine', `worktree-reconcile-failed-${rootSlug}`, [
1072
+ '# Worktree filesystem reconciliation failed closed',
1073
+ '',
1074
+ `**Worktree root:** ${group.wtParent}`,
1075
+ `**Error:** ${registrationError.message}`,
1076
+ '',
1077
+ 'No directories were removed because Git registrations could not be enumerated safely.',
1078
+ ].join('\n'));
1079
+ } catch {}
1080
+ }
1081
+ continue;
1082
+ }
1083
+
1084
+ let entries;
1085
+ try {
1086
+ entries = _fs.readdirSync(group.wtParent, { withFileTypes: true });
1087
+ } catch (err) {
1088
+ stats.failed++;
1089
+ result.failed++;
1090
+ failedRoots.add(worktreePool._normalizePath(group.wtParent));
1091
+ log('warn', `worktree-reconcile: readdir failed for ${group.wtParent}: ${err.message}`);
1092
+ continue;
1093
+ }
1094
+
1095
+ for (const entry of entries) {
1096
+ if (!entry || (typeof entry.isDirectory === 'function' && !entry.isDirectory())) continue;
1097
+ const name = entry.name || entry;
1098
+ if (typeof name !== 'string' || !name || shared.isWorktreeRootInfraEntry(name)) continue;
1099
+ const candidate = path.resolve(group.wtParent, name);
1100
+ if (!candidate.startsWith(group.wtParent + path.sep)) continue;
1101
+ stats.scanned++;
1102
+ result.scanned++;
1103
+
1104
+ const normalized = worktreePool._normalizePath(candidate);
1105
+ if (lockedRegisteredPaths.has(normalized)) {
1106
+ keep('registered', candidate);
1107
+ continue;
1108
+ }
1109
+ if (globalLiveDirNames.has(name) || _pathIsLiveFailOpen(_isWorktreePathLive, candidate, _liveOpts)) {
1110
+ keep('skippedLive', candidate);
1111
+ continue;
1112
+ }
1113
+ if (poolPaths.has(normalized)) {
1114
+ keep('pooled', candidate);
1115
+ continue;
1116
+ }
1117
+ if (_isWorktreeCwdAnchored(candidate, anchoredCwds)) {
1118
+ keep('anchored', candidate);
1119
+ continue;
1120
+ }
1121
+ let protectedQuarantine = true;
1122
+ try { protectedQuarantine = !!_hasProtectedQuarantineMarker(candidate); } catch {}
1123
+ if (protectedQuarantine) {
1124
+ keep('protected', candidate);
1125
+ continue;
1126
+ }
1127
+
1128
+ let owned = false;
1129
+ let legacyOwned = false;
1130
+ try { owned = !!_hasOwnerMarker(candidate); } catch {}
1131
+ if (!owned) {
1132
+ for (const commonGitDir of commonGitDirs) {
1133
+ if (isLegacyMinionsWorktreeDir(candidate, commonGitDir, { fs: _fs })) {
1134
+ owned = true;
1135
+ legacyOwned = true;
1136
+ break;
1137
+ }
1138
+ }
1139
+ }
1140
+ if (!owned) {
1141
+ keep('foreign', candidate);
1142
+ continue;
1143
+ }
1144
+ let stat;
1145
+ try { stat = _fs.statSync(candidate); }
1146
+ catch {
1147
+ keep(null, candidate);
1148
+ continue;
1149
+ }
1150
+ const quarantine = _isQuarantineDirName(name);
1151
+ const createdAt = quarantine
1152
+ ? _quarantineCreatedAt(name, stat.mtimeMs)
1153
+ : stat.mtimeMs;
1154
+ const retentionMs = quarantine ? quarantineRetentionMs : orphanRetentionMs;
1155
+ if ((now - createdAt) < retentionMs) {
1156
+ keep('recent', candidate);
1157
+ continue;
1158
+ }
1159
+
1160
+ // Adopt legacy residue immediately before deletion. The pre-adoption
1161
+ // stat above keeps the original retention clock; the marker makes a
1162
+ // partially removed husk provably owned on the next retry.
1163
+ if (legacyOwned && !_writeOwnerMarker(candidate, { reconciledLegacy: true })) {
1164
+ stats.failed++; result.failed++;
1165
+ keep('foreign', candidate);
1166
+ log('warn', `worktree-reconcile: could not stamp legacy ownership marker for ${candidate}; preserving`);
1167
+ continue;
1168
+ }
1169
+
1170
+ if (isProcessHeld(candidate)) {
1171
+ if (holderProbeError) {
1172
+ keep(null, candidate);
1173
+ } else {
1174
+ keep('processHeld', candidate);
1175
+ }
1176
+ continue;
1177
+ }
1178
+ if (registeredPaths.has(normalized)) {
1179
+ // Preserve here without shielding safe, unlocked registrations from downstream pruning.
1180
+ keep('registered', candidate, false);
1181
+ continue;
1182
+ }
1183
+
1184
+ try {
1185
+ const rootDir = group.projects[0].rootDir;
1186
+ const removed = _removeWorktree(candidate, rootDir, group.wtParent, {
1187
+ config: opts.config,
1188
+ });
1189
+ if (removed) {
1190
+ stats.removed++; result.removed++;
1191
+ try { shared.bumpWorktreeGcMetric('reconciledStaleDirs'); } catch {}
1192
+ log('info', `worktree-reconcile: removed stale unregistered directory ${candidate}`);
1193
+ } else {
1194
+ stats.failed++; result.failed++;
1195
+ log('warn', `worktree-reconcile: removeWorktree declined ${candidate}`);
1196
+ }
1197
+ } catch (err) {
1198
+ stats.failed++; result.failed++;
1199
+ log('warn', `worktree-reconcile: removal failed for ${candidate}: ${err.message}`);
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ return result;
1205
+ }
1206
+
721
1207
  /**
722
1208
  * Walk each project's worktree root and evict orphan W-* dirs.
723
1209
  *
@@ -740,7 +1226,8 @@ function gcDispatchWorktreeIfOrphan(opts) {
740
1226
  * completed dispatch with long-running services survives engine
741
1227
  * restart. (PR #2627 review — Issue 1)
742
1228
  *
743
- * Returns `{ scanned, kept, evicted, failed, perProject }`.
1229
+ * Returns legacy `{ scanned, kept, evicted, failed }` counters plus explicit
1230
+ * `{ removed, skipped, skippedLive, preserved, perProject }` outcomes.
744
1231
  */
745
1232
  function pruneOrphanWorktrees(opts) {
746
1233
  opts = opts || {};
@@ -762,6 +1249,12 @@ function pruneOrphanWorktrees(opts) {
762
1249
  const _hasOwnerMarker = typeof opts.hasOwnerMarker === 'function'
763
1250
  ? opts.hasOwnerMarker
764
1251
  : shared.hasWorktreeOwnerMarker;
1252
+ const _isWorktreePathLive = typeof opts.isWorktreePathLive === 'function'
1253
+ ? opts.isWorktreePathLive
1254
+ : shared.isWorktreePathLive;
1255
+ const _liveOpts = opts.db ? { db: opts.db } : undefined;
1256
+ const protectedPaths = opts.protectedPaths instanceof Set ? opts.protectedPaths : new Set();
1257
+ const failedRoots = opts.failedRoots instanceof Set ? opts.failedRoots : new Set();
765
1258
  const _listManagedSpecs = typeof opts.listManagedSpecs === 'function'
766
1259
  ? opts.listManagedSpecs
767
1260
  : (() => {
@@ -802,7 +1295,12 @@ function pruneOrphanWorktrees(opts) {
802
1295
  if (d.worktreePath) {
803
1296
  try { globalLiveDirNames.add(path.basename(path.resolve(d.worktreePath))); } catch {}
804
1297
  }
805
- if (!d.meta || !d.meta.branch) continue;
1298
+ if (!d.meta) continue;
1299
+ let dispatchBranch = d.meta.branch;
1300
+ if (!dispatchBranch && d.meta.project) {
1301
+ dispatchBranch = shared.READ_ONLY_ROOT_TASK_TYPES.has(d.type) ? 'read-only' : 'branchless';
1302
+ }
1303
+ if (!dispatchBranch) continue;
806
1304
  const dispatchProject = typeof d.meta.project === 'string'
807
1305
  ? d.meta.project
808
1306
  : (d.meta.project?.name || 'default');
@@ -810,14 +1308,18 @@ function pruneOrphanWorktrees(opts) {
810
1308
  globalLiveDirNames.add(_buildWorktreeDirName({
811
1309
  dispatchId: d.id,
812
1310
  projectName: dispatchProject,
813
- branchName: d.meta.branch,
1311
+ branchName: dispatchBranch,
814
1312
  }));
815
1313
  } catch (_e) { /* defensive */ }
816
1314
  }
817
1315
 
818
1316
  const anchoredCwds = _collectAnchoredCwds(_listManagedSpecs, _listKeepProcessCwds);
819
1317
 
820
- const result = { scanned: 0, kept: 0, evicted: 0, failed: 0, perProject: {} };
1318
+ const result = {
1319
+ scanned: 0, kept: 0, evicted: 0, failed: 0,
1320
+ removed: 0, skipped: 0, skippedLive: 0, preserved: 0,
1321
+ perProject: {},
1322
+ };
821
1323
 
822
1324
  for (const project of projects) {
823
1325
  if (!project || !project.localPath) continue;
@@ -827,6 +1329,10 @@ function pruneOrphanWorktrees(opts) {
827
1329
  try { rootExists = _fs.existsSync(rootDir); } catch { rootExists = false; }
828
1330
  if (!rootExists) continue;
829
1331
  const wtParent = path.resolve(rootDir, worktreeRootRel);
1332
+ if (failedRoots.has(worktreePool._normalizePath(wtParent))) {
1333
+ log('warn', `worktree-gc: skipping ${wtParent} because filesystem reconciliation failed closed`);
1334
+ continue;
1335
+ }
830
1336
  let parentExists = false;
831
1337
  try { parentExists = _fs.existsSync(wtParent); } catch { parentExists = false; }
832
1338
  if (!parentExists) continue;
@@ -838,7 +1344,10 @@ function pruneOrphanWorktrees(opts) {
838
1344
  continue;
839
1345
  }
840
1346
 
841
- const projStats = { scanned: 0, kept: 0, evicted: 0, failed: 0 };
1347
+ const projStats = {
1348
+ scanned: 0, kept: 0, evicted: 0, failed: 0,
1349
+ removed: 0, skipped: 0, skippedLive: 0, preserved: 0,
1350
+ };
842
1351
  for (const ent of entries) {
843
1352
  if (!ent || (typeof ent.isDirectory === 'function' && !ent.isDirectory())) continue;
844
1353
  const name = ent.name || ent;
@@ -848,23 +1357,72 @@ function pruneOrphanWorktrees(opts) {
848
1357
  projStats.scanned++;
849
1358
  result.scanned++;
850
1359
  const wtPath = path.join(wtParent, name);
1360
+ if (protectedPaths.has(worktreePool._normalizePath(wtPath))) {
1361
+ projStats.kept++; result.kept++;
1362
+ projStats.preserved++; result.preserved++;
1363
+ continue;
1364
+ }
851
1365
 
852
1366
  // 1. Live-dispatch protection (cross-project safe).
853
- if (globalLiveDirNames.has(name)) {
1367
+ const retryMarker = name.lastIndexOf(shared.WORKTREE_RETRY_PATH_MARKER);
1368
+ const liveBaseName = retryMarker > 0 ? name.slice(0, retryMarker) : name;
1369
+ if (globalLiveDirNames.has(name) || globalLiveDirNames.has(liveBaseName)) {
854
1370
  projStats.kept++; result.kept++;
1371
+ projStats.skipped++; result.skipped++;
1372
+ projStats.skippedLive++; result.skippedLive++;
1373
+ continue;
1374
+ }
1375
+ // The persisted path is authoritative. Derived directory names are only
1376
+ // a pre-persistence fallback and can miss legacy or reused worktrees.
1377
+ if (_pathIsLiveFailOpen(_isWorktreePathLive, wtPath, _liveOpts)) {
1378
+ projStats.kept++; result.kept++;
1379
+ projStats.skipped++; result.skipped++;
1380
+ projStats.skippedLive++; result.skippedLive++;
855
1381
  continue;
856
1382
  }
857
1383
  // 2. Pool membership protection.
858
1384
  const normPath = worktreePool._normalizePath(wtPath);
859
1385
  if (poolPaths.has(normPath)) {
860
1386
  projStats.kept++; result.kept++;
1387
+ projStats.preserved++; result.preserved++;
861
1388
  continue;
862
1389
  }
863
1390
  // 3. managed_spawn / keep_processes cwd anchor protection.
864
1391
  if (_isWorktreeCwdAnchored(wtPath, anchoredCwds)) {
865
1392
  projStats.kept++; result.kept++;
1393
+ projStats.preserved++; result.preserved++;
866
1394
  continue;
867
1395
  }
1396
+ // Quarantine directories are recovery artifacts, not ordinary crash
1397
+ // residue. Keep them for a deterministic recovery window, and keep
1398
+ // protected quarantines indefinitely when no durable backup ref exists.
1399
+ if (_isQuarantineDirName(name)) {
1400
+ let protectedQuarantine = true;
1401
+ try { protectedQuarantine = shared.hasProtectedQuarantineMarker(wtPath); } catch {}
1402
+ if (protectedQuarantine) {
1403
+ projStats.kept++; result.kept++;
1404
+ projStats.preserved++; result.preserved++;
1405
+ continue;
1406
+ }
1407
+ let quarantineStat;
1408
+ try { quarantineStat = _fs.statSync(wtPath); }
1409
+ catch {
1410
+ projStats.kept++; result.kept++;
1411
+ projStats.preserved++; result.preserved++;
1412
+ continue;
1413
+ }
1414
+ const now = Number.isFinite(Number(opts.now)) ? Number(opts.now) : Date.now();
1415
+ const retentionMs = Math.max(0,
1416
+ Number.isFinite(Number(opts.quarantineRetentionMs))
1417
+ ? Number(opts.quarantineRetentionMs)
1418
+ : DEFAULT_QUARANTINE_RETENTION_MS);
1419
+ const createdAt = _quarantineCreatedAt(name, quarantineStat.mtimeMs);
1420
+ if ((now - createdAt) < retentionMs) {
1421
+ projStats.kept++; result.kept++;
1422
+ projStats.preserved++; result.preserved++;
1423
+ continue;
1424
+ }
1425
+ }
868
1426
 
869
1427
  // P-c7e2b405 — route the slow-retry gate, ownership-marker gate (now
870
1428
  // requireOwnerMarker:true to match the out-of-root pruner), remove,
@@ -888,6 +1446,7 @@ function pruneOrphanWorktrees(opts) {
888
1446
  writeToInbox: opts.writeToInbox,
889
1447
  sleepSyncFn: opts.sleepSyncFn,
890
1448
  stats: [projStats, result],
1449
+ deferredRetries: opts.deferredRetries,
891
1450
  removeWorktree: _removeWorktree,
892
1451
  hasOwnerMarker: _hasOwnerMarker,
893
1452
  log,
@@ -932,7 +1491,9 @@ function pruneOrphanWorktrees(opts) {
932
1491
  * 4. Path is in the worktree-pool → keep.
933
1492
  * 5. managed_spawn cwd anchored → keep.
934
1493
  *
935
- * Returns `{ scanned, kept, evicted, failed, prunedRegistry, perProject }`.
1494
+ * Returns legacy `{ scanned, kept, evicted, failed, prunedRegistry }` counters
1495
+ * plus explicit `{ removed, skipped, skippedLive, preserved, perProject }`
1496
+ * outcomes.
936
1497
  *
937
1498
  * W-mq5o6bvy000x7191 Layer 3.
938
1499
  */
@@ -983,6 +1544,10 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
983
1544
  const _hasOwnerMarker = typeof opts.hasOwnerMarker === 'function'
984
1545
  ? opts.hasOwnerMarker
985
1546
  : shared.hasWorktreeOwnerMarker;
1547
+ const _isWorktreePathLive = typeof opts.isWorktreePathLive === 'function'
1548
+ ? opts.isWorktreePathLive
1549
+ : shared.isWorktreePathLive;
1550
+ const _liveOpts = opts.db ? { db: opts.db } : undefined;
986
1551
 
987
1552
  // Branches of active/pending dispatches (with and without `refs/heads/`
988
1553
  // normalization). Matched case-insensitively to mirror git's behavior on
@@ -1010,7 +1575,11 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
1010
1575
 
1011
1576
  const anchoredCwds = _collectAnchoredCwds(_listManagedSpecs, _listKeepProcessCwds);
1012
1577
 
1013
- const result = { scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0, perProject: {} };
1578
+ const result = {
1579
+ scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0,
1580
+ removed: 0, skipped: 0, skippedLive: 0, preserved: 0,
1581
+ perProject: {},
1582
+ };
1014
1583
  const _seenAbs = new Set(); // dedup across projects sharing a parent
1015
1584
 
1016
1585
  for (const project of projects) {
@@ -1026,7 +1595,10 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
1026
1595
  const wtParentAbs = path.resolve(rootDir, worktreeRootRel);
1027
1596
  const wtParentPrefix = wtParentAbs + path.sep;
1028
1597
 
1029
- const projStats = { scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0 };
1598
+ const projStats = {
1599
+ scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0,
1600
+ removed: 0, skipped: 0, skippedLive: 0, preserved: 0,
1601
+ };
1030
1602
  let trees;
1031
1603
  if (_parsedTreesByProject && _parsedTreesByProject.has(rootDir)) {
1032
1604
  trees = _parsedTreesByProject.get(rootDir);
@@ -1065,16 +1637,29 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
1065
1637
 
1066
1638
  // Branch protection
1067
1639
  if (wt.branch && protectedBranches.has(String(wt.branch).toLowerCase())) {
1068
- projStats.kept++; result.kept++; continue;
1640
+ projStats.kept++; result.kept++;
1641
+ projStats.skipped++; result.skipped++;
1642
+ projStats.skippedLive++; result.skippedLive++;
1643
+ continue;
1644
+ }
1645
+ if (_pathIsLiveFailOpen(_isWorktreePathLive, wtAbs, _liveOpts)) {
1646
+ projStats.kept++; result.kept++;
1647
+ projStats.skipped++; result.skipped++;
1648
+ projStats.skippedLive++; result.skippedLive++;
1649
+ continue;
1069
1650
  }
1070
1651
  // Pool protection
1071
1652
  const normPath = worktreePool._normalizePath(wtAbs);
1072
1653
  if (poolPaths.has(normPath)) {
1073
- projStats.kept++; result.kept++; continue;
1654
+ projStats.kept++; result.kept++;
1655
+ projStats.preserved++; result.preserved++;
1656
+ continue;
1074
1657
  }
1075
1658
  // managed_spawn / keep_processes cwd anchor protection.
1076
1659
  if (_isWorktreeCwdAnchored(wtAbs, anchoredCwds)) {
1077
- projStats.kept++; result.kept++; continue;
1660
+ projStats.kept++; result.kept++;
1661
+ projStats.preserved++; result.preserved++;
1662
+ continue;
1078
1663
  }
1079
1664
 
1080
1665
  // P-c7e2b405 — ownership-marker gate, slow-retry gate, remove, escalate,
@@ -1103,6 +1688,7 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
1103
1688
  writeToInbox: opts.writeToInbox,
1104
1689
  sleepSyncFn: opts.sleepSyncFn,
1105
1690
  stats: [projStats, result],
1691
+ deferredRetries: opts.deferredRetries,
1106
1692
  removeWorktree: _removeWorktree,
1107
1693
  hasOwnerMarker: _hasOwnerMarker,
1108
1694
  log,
@@ -1168,7 +1754,8 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
1168
1754
  * when set for a project, skip the self-shell of `git worktree list
1169
1755
  * --porcelain` and reuse the supplied parsed trees).
1170
1756
  *
1171
- * Returns `{ scanned, reclaimed, skippedLive, failed, perProject }` where
1757
+ * Returns `{ scanned, reclaimed, skippedLive, failed, removed, skipped,
1758
+ * preserved, perProject }` where
1172
1759
  * `scanned` counts only the missing-dir candidates considered (dirs that still
1173
1760
  * exist are skipped before scanning).
1174
1761
  */
@@ -1196,7 +1783,11 @@ function reclaimMissingDirWorktrees(opts) {
1196
1783
  : shared.isWorktreePathLive;
1197
1784
  const _liveOpts = opts.db ? { db: opts.db } : undefined;
1198
1785
 
1199
- const result = { scanned: 0, reclaimed: 0, skippedLive: 0, failed: 0, perProject: {} };
1786
+ const result = {
1787
+ scanned: 0, reclaimed: 0, skippedLive: 0, failed: 0,
1788
+ removed: 0, skipped: 0, preserved: 0,
1789
+ perProject: {},
1790
+ };
1200
1791
  const _seenAbs = new Set(); // dedup across projects sharing a parent repo
1201
1792
 
1202
1793
  for (const project of projects) {
@@ -1207,7 +1798,10 @@ function reclaimMissingDirWorktrees(opts) {
1207
1798
  try { rootExists = _fs.existsSync(rootDir); } catch { rootExists = false; }
1208
1799
  if (!rootExists) continue;
1209
1800
 
1210
- const projStats = { scanned: 0, reclaimed: 0, skippedLive: 0, failed: 0 };
1801
+ const projStats = {
1802
+ scanned: 0, reclaimed: 0, skippedLive: 0, failed: 0,
1803
+ removed: 0, skipped: 0, preserved: 0,
1804
+ };
1211
1805
  let trees;
1212
1806
  if (_parsedTreesByProject && _parsedTreesByProject.has(rootDir)) {
1213
1807
  trees = _parsedTreesByProject.get(rootDir);
@@ -1250,11 +1844,9 @@ function reclaimMissingDirWorktrees(opts) {
1250
1844
 
1251
1845
  // SAFETY 2 — never reclaim a path a non-terminal dispatch still claims
1252
1846
  // (fail-open: isWorktreePathLive returns true when SQL is unreachable).
1253
- let live = true;
1254
- try { live = !!_isWorktreePathLive(wt.path, _liveOpts); }
1255
- catch (_e) { live = true; }
1256
- if (live) {
1847
+ if (_pathIsLiveFailOpen(_isWorktreePathLive, wt.path, _liveOpts)) {
1257
1848
  projStats.skippedLive++; result.skippedLive++;
1849
+ projStats.skipped++; result.skipped++;
1258
1850
  log('debug', `worktree-gc: missing-dir reclaim skipping live-claimed ${wtAbs}`);
1259
1851
  continue;
1260
1852
  }
@@ -1265,9 +1857,32 @@ function reclaimMissingDirWorktrees(opts) {
1265
1857
  });
1266
1858
  reclaimedHere++;
1267
1859
  projStats.reclaimed++; result.reclaimed++;
1860
+ projStats.removed++; result.removed++;
1268
1861
  try { shared.bumpWorktreeGcMetric('missingDirReclaimed'); } catch { /* metric optional */ }
1269
1862
  log('info', `worktree-gc: reclaimed missing-dir worktree ${wtAbs}${wt.locked ? ' (was locked)' : ''}${wt.branch ? ` [branch ${wt.branch}]` : ''}`);
1270
1863
  } catch (e) {
1864
+ // Another cleanup path may have removed the registration after this
1865
+ // sweep captured its shared porcelain snapshot. Re-list before calling
1866
+ // an idempotently completed race a failure.
1867
+ let stillRegistered = true;
1868
+ try {
1869
+ const freshRaw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
1870
+ cwd: rootDir, timeout: 15000, windowsHide: true,
1871
+ }) || '');
1872
+ const freshTrees = _parseWorktreePorcelain(freshRaw);
1873
+ const targetNorm = worktreePool._normalizePath(wtAbs);
1874
+ stillRegistered = freshTrees.some((fresh) => {
1875
+ return fresh && fresh.path
1876
+ && worktreePool._normalizePath(fresh.path) === targetNorm;
1877
+ });
1878
+ } catch { /* keep the original failure authoritative */ }
1879
+ if (!stillRegistered) {
1880
+ reclaimedHere++;
1881
+ projStats.reclaimed++; result.reclaimed++;
1882
+ projStats.removed++; result.removed++;
1883
+ log('debug', `worktree-gc: missing-dir registration already absent after concurrent cleanup ${wtAbs}`);
1884
+ continue;
1885
+ }
1271
1886
  projStats.failed++; result.failed++;
1272
1887
  log('warn', `worktree-gc: missing-dir reclaim remove -f -f failed for ${wtAbs}: ${(e.message || '').split('\n')[0]}`);
1273
1888
  }
@@ -1292,10 +1907,14 @@ function reclaimMissingDirWorktrees(opts) {
1292
1907
  module.exports = {
1293
1908
  shouldGcDispatchWorktree,
1294
1909
  gcDispatchWorktreeIfOrphan,
1910
+ reconcileStaleWorktreeDirectories,
1295
1911
  pruneOrphanWorktrees,
1296
1912
  pruneOrphanWorktreesFromGitRegistry,
1297
1913
  reclaimMissingDirWorktrees, // W-mqifblkf00149df5 — locked-initializing missing-dir reaper
1298
1914
  reapAndRemoveWorktree, // P-c7e2b405 — shared reap-then-remove core (exported for testing)
1915
+ isLegacyMinionsWorktreeDir, // exported for testing
1916
+ DEFAULT_ORPHAN_RETENTION_MS,
1917
+ DEFAULT_QUARANTINE_RETENTION_MS,
1299
1918
  // exported for testing (W-mq5o6bvy000x7191)
1300
1919
  _resetStuckPathsForTesting,
1301
1920
  _stuckPaths,