@yemi33/minions 0.1.2423 → 0.1.2425

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 (184) 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 +6 -4
  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/internal-install.md +190 -0
  55. package/docs/kb-pr3223-cascade-archiving.md +16 -0
  56. package/docs/kb-pr696-merge-conflict-docs.md +23 -0
  57. package/docs/kb-sweep.md +2 -2
  58. package/docs/keep-processes.md +8 -1
  59. package/docs/live-checkout-mode.md +12 -7
  60. package/docs/managed-spawn.md +21 -3
  61. package/docs/named-agents.md +3 -2
  62. package/docs/plan-lifecycle.md +32 -32
  63. package/docs/pr-auto-fix-dispatch.md +3 -3
  64. package/docs/pr-review-fix-loop.md +6 -6
  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/03-plan-driven-feature.md +3 -3
  82. package/docs/tutorials/04-runtimes-and-harness.md +18 -21
  83. package/docs/tutorials/08-operations-and-recovery.md +3 -2
  84. package/docs/tutorials/README.md +4 -3
  85. package/docs/visual-evidence-ci.md +103 -0
  86. package/docs/watches.md +35 -30
  87. package/docs/worktree-lifecycle.md +151 -13
  88. package/engine/acp-transport.js +1 -0
  89. package/engine/ado.js +80 -33
  90. package/engine/agent-api-validation.js +571 -0
  91. package/engine/agent-worker-pool.js +7 -6
  92. package/engine/api-contracts/agent-content.js +761 -0
  93. package/engine/api-contracts/cc-ops.js +219 -0
  94. package/engine/api-contracts/config-runtime.js +419 -0
  95. package/engine/api-contracts/core.js +138 -0
  96. package/engine/api-contracts/index.js +506 -0
  97. package/engine/api-contracts/orchestration.js +602 -0
  98. package/engine/api-contracts/pull-requests.js +323 -0
  99. package/engine/api-contracts/qa-process.js +764 -0
  100. package/engine/api-contracts/work-plan-prd.js +494 -0
  101. package/engine/api-validation.js +543 -0
  102. package/engine/cc-api-validation.js +586 -0
  103. package/engine/cc-worker-pool.js +9 -1
  104. package/engine/cleanup.js +393 -271
  105. package/engine/cli.js +65 -7
  106. package/engine/comment-classifier.js +70 -5
  107. package/engine/consolidation.js +123 -5
  108. package/engine/content-api-validation.js +606 -0
  109. package/engine/create-pr-worktree.js +22 -1
  110. package/engine/db/migrations/027-review-learning-lifecycle.js +162 -0
  111. package/engine/dispatch.js +33 -0
  112. package/engine/distribution.js +189 -0
  113. package/engine/execution-model.js +59 -0
  114. package/engine/features.js +12 -0
  115. package/engine/github.js +67 -25
  116. package/engine/harness-context.js +313 -0
  117. package/engine/inbox-store.js +1 -1
  118. package/engine/kb-sweep.js +161 -38
  119. package/engine/keep-process-sweep.js +43 -3
  120. package/engine/lifecycle.js +1066 -165
  121. package/engine/llm.js +4 -0
  122. package/engine/managed-spawn.js +463 -48
  123. package/engine/meeting.js +197 -34
  124. package/engine/memory-retrieval.js +280 -0
  125. package/engine/memory-store.js +821 -38
  126. package/engine/model-discovery.js +17 -3
  127. package/engine/pipeline.js +802 -14
  128. package/engine/plan-prd-validation.js +745 -0
  129. package/engine/playbook.js +206 -17
  130. package/engine/pooled-agent-process.js +6 -1
  131. package/engine/pr-action.js +1 -1
  132. package/engine/pr-issue-validation.js +653 -0
  133. package/engine/pr-resolve.js +75 -13
  134. package/engine/prd-store.js +39 -6
  135. package/engine/preflight.js +7 -2
  136. package/engine/process-utils.js +79 -31
  137. package/engine/projects.js +198 -12
  138. package/engine/promotion.js +371 -0
  139. package/engine/qa-from-prd.js +3 -0
  140. package/engine/qa-process-validation.js +572 -0
  141. package/engine/qa-runbooks.js +41 -37
  142. package/engine/qa-runners/maestro.js +11 -7
  143. package/engine/qa-runners/playwright.js +37 -14
  144. package/engine/qa-runners.js +9 -10
  145. package/engine/qa-runs.js +124 -11
  146. package/engine/qa-sessions.js +139 -40
  147. package/engine/quarantine-refs.js +81 -11
  148. package/engine/queries.js +163 -9
  149. package/engine/review-learning-backfill.js +482 -0
  150. package/engine/review-learning.js +1236 -0
  151. package/engine/runtimes/claude.js +60 -2
  152. package/engine/runtimes/codex.js +57 -16
  153. package/engine/runtimes/contract.js +21 -0
  154. package/engine/runtimes/copilot.js +51 -3
  155. package/engine/runtimes/index.js +1 -0
  156. package/engine/scheduler.js +201 -32
  157. package/engine/settings-validation.js +909 -0
  158. package/engine/shared.js +705 -208
  159. package/engine/spawn-agent.js +48 -21
  160. package/engine/steering-constraints.js +31 -0
  161. package/engine/steering-store.js +10 -2
  162. package/engine/steering.js +14 -2
  163. package/engine/timeout.js +404 -111
  164. package/engine/untrusted-fence.js +24 -10
  165. package/engine/watch-actions.js +7 -2
  166. package/engine/watches.js +353 -51
  167. package/engine/work-item-validation.js +561 -0
  168. package/engine/work-items-store.js +61 -1
  169. package/engine/worktree-gc.js +656 -37
  170. package/engine/worktree-preflight.js +154 -0
  171. package/engine.js +2068 -342
  172. package/package.json +1 -1
  173. package/playbooks/_pr-description-audit.md +88 -41
  174. package/playbooks/build-fix-complex.md +3 -3
  175. package/playbooks/docs.md +2 -2
  176. package/playbooks/fix.md +18 -18
  177. package/playbooks/implement.md +18 -18
  178. package/playbooks/qa-session-draft.md +10 -12
  179. package/playbooks/qa-session-execute.md +16 -12
  180. package/playbooks/qa-validate.md +8 -7
  181. package/playbooks/shared-rules.md +45 -0
  182. package/playbooks/test.md +4 -0
  183. package/prompts/cc-system.md +1 -1
  184. package/skills/check-self-authored-review-comment/SKILL.md +2 -2
package/engine/cleanup.js CHANGED
@@ -28,10 +28,8 @@ function engine() { if (!_engine) _engine = require('../engine'); return _engine
28
28
  let _dispatch = null;
29
29
  function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
30
30
 
31
- // Lazy require for worktree-pool used to protect pool entries from age/cap
32
- // sweeps (W-mp73ya3e000me6c5). Borrowed entries with live dispatches are
33
- // always protected; idle entries survive the age sweep but stay eligible for
34
- // the cap sweep so MAX_WORKTREES is still honored.
31
+ // Lazy require for worktree-pool. Borrowed entries with live dispatches are
32
+ // protected; idle-entry TTL cleanup remains owned by worktree-pool.pruneStale.
35
33
  let _worktreePool = null;
36
34
  function worktreePool() {
37
35
  if (!_worktreePool) _worktreePool = require('./worktree-pool');
@@ -92,9 +90,7 @@ function localBranchWorktreeInUse(root, branch) {
92
90
  // P-e0b4f7a5 — collect branches of work items currently in the
93
91
  // phantom-completion retry state for a given project. Returns a Set of
94
92
  // branch strings. Used by the worktree cleanup loop to protect worktrees
95
- // belonging to in-flight phantom retries from the 2-hour age sweep —
96
- // without this protection the agent's pushed branch reference could be
97
- // destroyed alongside the worktree before the retry runs.
93
+ // belonging to in-flight phantom retries from terminal-PR cleanup.
98
94
  function collectPhantomBranchesForProject(project) {
99
95
  const branches = new Set();
100
96
  try {
@@ -318,61 +314,6 @@ async function getWorktreeBranchAsync(wtPath) {
318
314
  }
319
315
  }
320
316
 
321
- /**
322
- * Kill orphaned processes whose dispatch ID appears in the worktree dir name.
323
- * Only kills processes NOT in the active dispatch queue — never kills live agents.
324
- */
325
- function _killProcessInWorktree(dir, activeProcesses, activeIds) {
326
- const dirLower = dir.toLowerCase();
327
-
328
- // Check tracked in-memory processes — only kill if dispatch is no longer active
329
- for (const [id, info] of activeProcesses.entries()) {
330
- if (!dirLower.includes(id.toLowerCase().slice(-8))) continue;
331
- if (activeIds.has(id)) continue; // still active — do not kill
332
- try { shared.killImmediate(info.proc); } catch {}
333
- activeProcesses.delete(id);
334
- log('info', `Killed orphaned process for dispatch ${id} before worktree removal`);
335
- }
336
-
337
- // Check PID files in engine/tmp/ — both legacy flat layout and per-dispatch
338
- // dirs (P-f6-tmp-toctou). Only kill if no active dispatch matches.
339
- try {
340
- shared.forEachPidFile((pidFilePath, fileName, layout) => {
341
- const pidFileName = fileName.replace(/^pid-/, '').replace(/\.pid$/, '');
342
- if (!dirLower.includes(pidFileName.slice(-8))) return;
343
- // Verify this PID file's dispatch is not active
344
- let isActive = false;
345
- for (const id of activeIds) { if (pidFileName.includes(id.slice(-8))) { isActive = true; break; } }
346
- if (isActive) return; // still active — do not kill
347
- let pid;
348
- try { pid = parseInt(fs.readFileSync(pidFilePath, 'utf8').trim(), 10); }
349
- catch { return; }
350
- if (pid > 0) {
351
- // Verify the PID still belongs to a Minions runtime process before killing.
352
- // The shared helper inspects the PID's full command line for `claude` /
353
- // `copilot` so a recycled PID running an unrelated process is skipped.
354
- try {
355
- if (process.platform === 'win32') {
356
- if (!shared.isProcessCommandLineMatchingAgent(pid)) return;
357
- exec(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe', timeout: 5000, windowsHide: true });
358
- } else {
359
- if (!shared.isProcessCommandLineMatchingAgent(pid)) return;
360
- try { process.kill(-pid, 'SIGKILL'); } catch { process.kill(pid, 'SIGKILL'); }
361
- }
362
- log('info', `Killed orphaned PID ${pid} (${fileName}, ${layout}) before worktree removal`);
363
- } catch {} // process may already be dead
364
- }
365
- if (layout === 'dispatch-dir') {
366
- // Remove the entire per-dispatch dir — its remaining sidecars are
367
- // orphans of the same dead process.
368
- try { shared.removeDispatchTmpDir(path.dirname(pidFilePath)); } catch {}
369
- } else {
370
- try { fs.unlinkSync(pidFilePath); } catch {}
371
- }
372
- });
373
- } catch {} // tmp dir may not exist
374
- }
375
-
376
317
  // Agent scratch (`.agent-temp` under the worktree root) holds throwaway git
377
318
  // repos written by dispatched agents + the test-suite-run-by-agents. Nothing
378
319
  // owns its lifecycle, so it accumulates unboundedly (observed: 26k+ dirs). The
@@ -384,22 +325,41 @@ function _killProcessInWorktree(dir, activeProcesses, activeIds) {
384
325
  // across cleanup cycles.
385
326
  const AGENT_SCRATCH_TTL_MS = 6 * 60 * 60 * 1000; // 6h — comfortably > agentTimeout (5h), so only dead-dispatch scratch is reaped
386
327
  const AGENT_SCRATCH_MAX_SCAN_PER_CLEANUP = 2000;
328
+ const _agentScratchScanOffsets = new Map();
329
+ const LEGACY_LLM_TEMP_RE = /-(?:prompt|sys|sysprompt)-[^\\/]+\.md$/i;
387
330
 
388
- function reapAgentScratch(worktreeRoot) {
331
+ function reapAgentScratch(worktreeRoot, opts = {}) {
389
332
  const scratchDir = path.join(worktreeRoot, shared.WORKTREE_SCRATCH_DIR_NAME);
390
333
  let names;
391
- try { names = fs.readdirSync(scratchDir); } catch { return 0; } // no scratch dir → nothing to do
334
+ try { names = fs.readdirSync(scratchDir); } catch {
335
+ _agentScratchScanOffsets.delete(scratchDir);
336
+ return 0;
337
+ }
338
+ if (names.length === 0) {
339
+ _agentScratchScanOffsets.delete(scratchDir);
340
+ return 0;
341
+ }
392
342
  const cutoff = Date.now() - AGENT_SCRATCH_TTL_MS;
343
+ const maxScan = Number.isFinite(Number(opts.maxScan))
344
+ ? Math.max(1, Math.floor(Number(opts.maxScan)))
345
+ : AGENT_SCRATCH_MAX_SCAN_PER_CLEANUP;
393
346
  let reaped = 0;
394
- const limit = Math.min(names.length, AGENT_SCRATCH_MAX_SCAN_PER_CLEANUP);
347
+ const limit = Math.min(names.length, maxScan);
348
+ const start = (_agentScratchScanOffsets.get(scratchDir) || 0) % names.length;
395
349
  for (let i = 0; i < limit; i++) {
396
- const full = path.join(scratchDir, names[i]);
350
+ const full = path.join(scratchDir, names[(start + i) % names.length]);
397
351
  let stat;
398
352
  try { stat = fs.statSync(full); } catch { continue; }
399
353
  if (stat.mtimeMs >= cutoff) continue; // recent — may belong to a live dispatch
400
354
  try { fs.rmSync(full, { recursive: true, force: true }); reaped++; }
401
355
  catch { /* locked / in use — leave it for a later pass */ }
402
356
  }
357
+ const remaining = names.length - reaped;
358
+ if (remaining > 0) {
359
+ _agentScratchScanOffsets.set(scratchDir, (start + limit - reaped) % remaining);
360
+ } else {
361
+ _agentScratchScanOffsets.delete(scratchDir);
362
+ }
403
363
  if (reaped > 0) {
404
364
  log('info', `Reaped ${reaped} stale agent-scratch entries (>${Math.round(AGENT_SCRATCH_TTL_MS / 3600000)}h) from ${scratchDir}`);
405
365
  }
@@ -415,6 +375,7 @@ function reapAgentScratch(worktreeRoot) {
415
375
  const KNOWLEDGE_SCRATCH_RE = /(?:kb-cleanup-batch|consolidated-digest|consolidated-ci-health)/i;
416
376
  const KNOWLEDGE_SCRATCH_TTL_MS = 48 * 60 * 60 * 1000; // 48h — keep a couple days for reference, reap older
417
377
  const KNOWLEDGE_SCRATCH_MAX_SCAN = 5000;
378
+ const RUNTIME_ARTIFACT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
418
379
 
419
380
  function reapKnowledgeScratch(minionsDir = MINIONS_DIR) {
420
381
  const dir = path.join(minionsDir, 'knowledge');
@@ -440,6 +401,54 @@ function reapKnowledgeScratch(minionsDir = MINIONS_DIR) {
440
401
  return reaped;
441
402
  }
442
403
 
404
+ function pruneStaleEngineTmpFiles(opts = {}) {
405
+ const now = Number.isFinite(Number(opts.now)) ? Number(opts.now) : Date.now();
406
+ const tmpDir = path.join(ENGINE_DIR, 'tmp');
407
+ let entries;
408
+ try { entries = fs.readdirSync(tmpDir, { withFileTypes: true }); } catch { return 0; }
409
+ let pruned = 0;
410
+ for (const entry of entries) {
411
+ if (!entry.isFile() || /^pid-.*\.pid$/i.test(entry.name)) continue;
412
+ const full = path.join(tmpDir, entry.name);
413
+ let stat;
414
+ try { stat = fs.statSync(full); } catch { continue; }
415
+ if ((now - stat.mtimeMs) < RUNTIME_ARTIFACT_RETENTION_MS) continue;
416
+ try { fs.unlinkSync(full); pruned++; } catch { /* best-effort */ }
417
+ }
418
+ return pruned;
419
+ }
420
+
421
+ function pruneOrphanManagedLogs(opts = {}) {
422
+ const now = Number.isFinite(Number(opts.now)) ? Number(opts.now) : Date.now();
423
+ const specs = Array.isArray(opts.specs)
424
+ ? opts.specs
425
+ : require('./managed-spawn').listManagedSpecs();
426
+ const normalize = value => {
427
+ const resolved = path.resolve(String(value || ''));
428
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
429
+ };
430
+ const protectedLogs = new Set();
431
+ for (const spec of specs || []) {
432
+ if (!spec?.log_path) continue;
433
+ protectedLogs.add(normalize(spec.log_path));
434
+ protectedLogs.add(normalize(`${spec.log_path}.1`));
435
+ }
436
+ const logsDir = path.join(ENGINE_DIR, 'managed-logs');
437
+ let entries;
438
+ try { entries = fs.readdirSync(logsDir, { withFileTypes: true }); } catch { return 0; }
439
+ let pruned = 0;
440
+ for (const entry of entries) {
441
+ if (!entry.isFile() || !/\.log(?:\.1)?$/i.test(entry.name)) continue;
442
+ const full = path.join(logsDir, entry.name);
443
+ if (protectedLogs.has(normalize(full))) continue;
444
+ let stat;
445
+ try { stat = fs.statSync(full); } catch { continue; }
446
+ if ((now - stat.mtimeMs) < RUNTIME_ARTIFACT_RETENTION_MS) continue;
447
+ try { fs.unlinkSync(full); pruned++; } catch { /* best-effort */ }
448
+ }
449
+ return pruned;
450
+ }
451
+
443
452
  // ─── Cleanup Orchestrator ────────────────────────────────────────────────────
444
453
 
445
454
  async function runCleanup(config, verbose = false) {
@@ -487,7 +496,8 @@ async function runCleanup(config, verbose = false) {
487
496
  continue;
488
497
  }
489
498
  for (const f of dirEntries) {
490
- const isPromptTemp = f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-');
499
+ const isLegacyLlmTemp = dir === tmpDir && LEGACY_LLM_TEMP_RE.test(f);
500
+ const isPromptTemp = isLegacyLlmTemp || f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-');
491
501
  const isSafeWriteTemp = /\.tmp\.\d+\.\d+$/.test(f);
492
502
  if (isPromptTemp || isSafeWriteTemp) {
493
503
  const fp = path.join(dir, f);
@@ -518,6 +528,15 @@ async function runCleanup(config, verbose = false) {
518
528
  }
519
529
  } catch { /* sweep is best-effort */ }
520
530
  }
531
+ cleaned.staleRuntimeTmpFiles = 0;
532
+ try {
533
+ cleaned.staleRuntimeTmpFiles = pruneStaleEngineTmpFiles();
534
+ cleaned.tempFiles += cleaned.staleRuntimeTmpFiles;
535
+ } catch (e) { log('warn', `pruneStaleEngineTmpFiles: ${e.message}`); }
536
+ cleaned.orphanManagedLogs = 0;
537
+ try {
538
+ cleaned.orphanManagedLogs = pruneOrphanManagedLogs();
539
+ } catch (e) { log('warn', `pruneOrphanManagedLogs: ${e.message}`); }
521
540
 
522
541
  // 2. Clean live-output.log and live-output-prev.log for idle agents (not currently working)
523
542
  for (const [agentId] of Object.entries(config.agents || {})) {
@@ -615,100 +634,25 @@ async function runCleanup(config, verbose = false) {
615
634
  }
616
635
  }
617
636
 
618
- // 2d. Reap on-disk worktree dirs not registered in `git worktree list`. Can
619
- // be left behind when removeWorktree fails mid-way, when `git worktree prune`
620
- // ran without a follow-up rm -rf, or after manual `git worktree remove
621
- // --force` leaves an empty dir. Phase 3 below only walks dirs already in
622
- // git's list, so these are invisible to it. 2h mtime gate matches the
623
- // existing age sweep further down.
637
+ // 2d. Filesystem-authoritative stale-directory reconciliation. This is the
638
+ // sole destructive scan of the configured worktree root; periodic and boot
639
+ // maintenance use the same implementation.
624
640
  cleaned.orphanWorktreeDirs = 0;
625
- const _twoHoursAgo = Date.now() - 7200000;
626
- const _scannedWtRoots = new Set();
627
- for (const project of projects) {
628
- const root = project.localPath ? path.resolve(project.localPath) : null;
629
- if (!root || !fs.existsSync(root)) continue;
630
- const wtRoots = new Set();
631
- const configuredRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
632
- if (fs.existsSync(configuredRoot)) wtRoots.add(configuredRoot);
633
- for (const d of ['worktrees', '.claude/worktrees'].map(d => path.join(root, d))) {
634
- if (fs.existsSync(d)) wtRoots.add(d);
635
- }
636
- let registered = null;
637
- for (const wtRoot of wtRoots) {
638
- if (_scannedWtRoots.has(wtRoot)) continue;
639
- _scannedWtRoots.add(wtRoot);
640
- // Resolve `git worktree list` once per project; reused across its roots.
641
- if (registered === null) {
642
- try {
643
- const raw = String(shared.execSilent('git worktree list --porcelain', { cwd: root, timeout: 10000, windowsHide: true }) || '');
644
- registered = new Set(shared.parseWorktreePorcelain(raw).map(wt => path.resolve(wt.path)));
645
- } catch (e) {
646
- log('warn', `orphan worktree dir scan for ${project.name || root}: ${e.message}`);
647
- registered = new Set();
648
- }
649
- }
650
- let entries;
651
- try { entries = fs.readdirSync(wtRoot, { withFileTypes: true }); } catch { continue; }
652
- for (const entry of entries) {
653
- if (!entry.isDirectory()) continue;
654
- const full = path.resolve(wtRoot, entry.name);
655
- // Defensive boundary: only ever delete strictly inside the worktree
656
- // root we enumerated. readdirSync never yields `..`, so this is always
657
- // true today — but it makes the "never escape wtRoot" invariant
658
- // explicit and immune to a future refactor of how `full` is derived.
659
- if (full !== wtRoot && !full.startsWith(wtRoot + path.sep)) continue;
660
- if (registered.has(full)) continue;
661
- // W-mqecdoot — ownership gate, parity with the out-of-root worktree GC
662
- // (worktree-gc.pruneOrphanWorktreesFromGitRegistry). Only ever reap a
663
- // dir the engine positively created (carries the `.minions-worktree`
664
- // marker stamped right after `git worktree add`). A dir with NO marker
665
- // is a human's hand-made worktree (`git worktree add ../worktrees/mine`)
666
- // or some unrelated folder dropped into the worktree root — KEEP it,
667
- // never delete. Fail-open: leak a husk rather than nuke someone's
668
- // unpushed work. This closes the same data-loss class the marker fixed
669
- // for the registry sweep, for this on-disk-dir sweep too. The cost is
670
- // that empty leftover husks whose marker was already removed leak
671
- // instead of being reaped — an acceptable trade for "absolutely never
672
- // delete foreign data".
673
- let owned = false;
674
- try { owned = !!shared.hasWorktreeOwnerMarker(full); } catch { owned = false; }
675
- if (!owned) {
676
- log('debug', `Cleanup: keeping orphan worktree dir ${full} — no engine ownership marker (foreign/hand-made worktree)`);
677
- continue;
678
- }
679
- let stat; try { stat = fs.statSync(full); } catch { continue; }
680
- if (stat.mtimeMs >= _twoHoursAgo) continue;
681
- // W-mq5rwwss000f30a7 — even an "orphan" dir (one git doesn't know
682
- // about) may still host a live agent if dispatch persistence ran
683
- // before `git worktree add` finished. Skip when a live dispatch
684
- // claims it.
685
- if (shared.isWorktreePathLive(full)) {
686
- log('info', `Cleanup: skip orphan worktree dir ${full} — live dispatch claims it`);
687
- const blockingInfo = shared.getWorktreeBlockingDispatchInfo(full);
688
- shared._writeWorktreeSkipLiveInboxNote(full, 'cleanup.orphanWorktreeDirSweep', blockingInfo);
689
- continue;
690
- }
691
- // W-mr2zu0hb000f20c7 — route through shared.removeWorktree instead of a
692
- // raw fs.rmSync so this path gets the same retry-with-backoff (transient
693
- // Windows EBUSY/locked-file handling) and real-repo-refusal safety net
694
- // that every other worktree removal in this file uses. This dir already
695
- // isn't registered in `git worktree list`, so the primary
696
- // `git worktree remove --force` inside removeWorktree is expected to
697
- // fail and fall through to its fs.rmSync + retry logic — that's fine,
698
- // the safety checks (ownership marker already verified above; real-repo
699
- // and live-dispatch refusals re-checked inside removeWorktree) still apply.
700
- try {
701
- if (shared.removeWorktree(full, root, wtRoot)) {
702
- cleaned.orphanWorktreeDirs++;
703
- log('info', `Cleanup: removed orphan worktree dir ${full} (not registered in git)`);
704
- } else {
705
- log('warn', `orphan worktree dir ${full}: removeWorktree declined (see prior log line for reason)`);
706
- }
707
- } catch (err) {
708
- log('warn', `orphan worktree dir ${full}: ${err.message}`);
709
- }
710
- }
711
- }
641
+ cleaned.worktreeReconcileFailed = 0;
642
+ try {
643
+ const reconcile = require('./worktree-gc').reconcileStaleWorktreeDirectories({
644
+ projects,
645
+ dispatchSnap: getDispatch(),
646
+ worktreeRootRel: config.engine?.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot,
647
+ config,
648
+ log: (level, message) => log(level, message),
649
+ writeToInbox: (agent, slug, content) => shared.writeToInbox(agent, slug, content),
650
+ });
651
+ cleaned.orphanWorktreeDirs = reconcile.removed || 0;
652
+ cleaned.worktreeReconcileFailed = reconcile.failed || 0;
653
+ } catch (err) {
654
+ cleaned.worktreeReconcileFailed = 1;
655
+ log('warn', `worktree filesystem reconcile: ${err.message}`);
712
656
  }
713
657
 
714
658
  // 3. Clean git worktrees for merged/abandoned PRs
@@ -725,12 +669,26 @@ async function runCleanup(config, verbose = false) {
725
669
  const root = project.localPath ? path.resolve(project.localPath) : null;
726
670
  if (!root || !fs.existsSync(root)) continue;
727
671
 
728
- // Scan all potential worktree locations: configured root + common project-local dirs
672
+ // This phase only handles explicit merged/abandoned PR cleanup. Generic
673
+ // retention cleanup belongs to the authoritative reconciliation above.
674
+ let registeredByPath;
675
+ try {
676
+ const raw = String(shared.execSilent('git --no-optional-locks worktree list --porcelain', {
677
+ cwd: root, timeout: 15000, windowsHide: true,
678
+ }) || '');
679
+ registeredByPath = new Map(
680
+ shared.parseWorktreePorcelain(raw)
681
+ .filter(entry => entry?.path)
682
+ .map(entry => [worktreePool()._normalizePath(entry.path), entry]),
683
+ );
684
+ } catch (err) {
685
+ log('warn', `cleanup merged worktrees for ${project.name || root}: registry unavailable (${err.message}) — fail-closed`);
686
+ continue;
687
+ }
688
+
729
689
  const worktreeRoots = new Set();
730
690
  const configuredRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
731
691
  if (fs.existsSync(configuredRoot)) worktreeRoots.add(configuredRoot);
732
- const localDirs = ['worktrees', '.claude/worktrees'].map(d => path.join(root, d));
733
- for (const d of localDirs) { if (fs.existsSync(d)) worktreeRoots.add(d); }
734
692
 
735
693
  for (const worktreeRoot of worktreeRoots) {
736
694
 
@@ -750,8 +708,7 @@ async function runCleanup(config, verbose = false) {
750
708
  }
751
709
  }
752
710
 
753
- // List worktrees collect info for age-based + cap-based cleanup
754
- const MAX_WORKTREES = 10;
711
+ // List registered engine-owned worktrees for explicit terminal PR cleanup.
755
712
  try {
756
713
  // Collect all worktree directories (including nested ones like minions-work/P-xxx)
757
714
  const allDirs = [];
@@ -765,64 +722,53 @@ async function runCleanup(config, verbose = false) {
765
722
  if (shared.isWorktreeRootInfraEntry(dir)) continue;
766
723
  const dirPath = path.join(worktreeRoot, dir);
767
724
  try { if (!fs.statSync(dirPath).isDirectory()) continue; } catch { continue; }
768
- // Check if this is a git worktree (has .git file) or a parent directory
725
+ // Check if this is a registered engine-owned git worktree or a parent
726
+ // directory containing one. Unregistered residue is owned by phase 2d.
769
727
  if (fs.existsSync(path.join(dirPath, '.git'))) {
770
- allDirs.push({ dir, wtPath: dirPath });
728
+ const normalized = worktreePool()._normalizePath(dirPath);
729
+ if (registeredByPath.has(normalized)
730
+ && shared.hasWorktreeOwnerMarker(dirPath)
731
+ && !shared.hasProtectedQuarantineMarker(dirPath)
732
+ && !/-quarantine-(?:protected-)?\d+(?:$|-)/.test(dir)) {
733
+ allDirs.push({ dir, wtPath: dirPath, registration: registeredByPath.get(normalized) });
734
+ }
771
735
  } else {
772
736
  // Scan subdirectories for worktrees
773
737
  try {
774
738
  for (const sub of fs.readdirSync(dirPath)) {
775
739
  const subPath = path.join(dirPath, sub);
776
- try { if (fs.statSync(subPath).isDirectory()) allDirs.push({ dir: dir + '/' + sub, wtPath: subPath }); } catch { /* skip */ }
740
+ try {
741
+ if (!fs.statSync(subPath).isDirectory()) continue;
742
+ const normalized = worktreePool()._normalizePath(subPath);
743
+ if (registeredByPath.has(normalized)
744
+ && shared.hasWorktreeOwnerMarker(subPath)
745
+ && !shared.hasProtectedQuarantineMarker(subPath)
746
+ && !/-quarantine-(?:protected-)?\d+(?:$|-)/.test(sub)) {
747
+ allDirs.push({ dir: dir + '/' + sub, wtPath: subPath, registration: registeredByPath.get(normalized) });
748
+ }
749
+ } catch { /* skip */ }
777
750
  }
778
751
  } catch { /* skip */ }
779
752
  }
780
753
  }
781
754
 
782
- const wtEntries = []; // { dir, wtPath, mtime, shouldClean, isProtected }
755
+ if (allDirs.length === 0) continue;
756
+
757
+ const wtEntries = []; // { dir, wtPath, shouldClean, isProtected }
783
758
  const dispatch = getDispatch();
784
759
  const activeDispatchIds = new Set((dispatch.active || []).map(d => d.id));
785
- // P-e0b4f7a5 — branches whose work item is mid-phantom-retry. Their
786
- // worktrees must survive the age/cap sweep until the retry completes
787
- // (or exhausts its budget) so the agent's already-pushed branch ref
788
- // isn't destroyed alongside the worktree.
760
+ // P-e0b4f7a5 — branches whose work item is mid-phantom-retry survive
761
+ // terminal-PR cleanup until the retry completes or exhausts its budget.
789
762
  const phantomBranches = collectPhantomBranchesForProject(project);
790
763
 
791
- // W-mp73ya3e000me6c5 — worktree pool protection sets. Borrowed entries
792
- // with live dispatches are ALWAYS protected (no age, no cap). Idle
793
- // entries survive the age sweep but stay eligible for the cap sweep so
794
- // MAX_WORKTREES is still honored.
764
+ // W-mp73ya3e000me6c5 — borrowed entries with live dispatches are
765
+ // protected; idle pool TTL is handled by worktree-pool.pruneStale.
795
766
  let _activeBorrowedPaths = new Set();
796
- let _idlePaths = new Set();
797
767
  try {
798
768
  _activeBorrowedPaths = worktreePool().getActiveBorrowedPaths(activeDispatchIds);
799
- _idlePaths = worktreePool().getIdlePaths();
800
769
  } catch (e) { log('warn', `worktree-pool: cleanup lookup failed: ${e.message}`); }
801
770
  const _normalizePoolPath = worktreePool()._normalizePath;
802
771
 
803
- // W-mpbinmrh001907e9 — managed-spawn cwd anchor protection. After a
804
- // managed_spawn dispatch succeeds, engine-owned services run with
805
- // `cwd` inside the dispatch's worktree; the agent is gone so no
806
- // active dispatch references the branch and the >2h age sweep would
807
- // reap the worktree from under the live services. Build a list of
808
- // normalized cwd values from managed-process state and protect any
809
- // worktree dir that contains one. Cwd is optional per the schema, so
810
- // entries without cwd contribute nothing.
811
- const _anchoredProcessCwds = [];
812
- try {
813
- const _ms = require('./managed-spawn');
814
- for (const rec of _ms.listManagedSpecs()) {
815
- if (rec && typeof rec.cwd === 'string' && rec.cwd.length > 0) {
816
- try { _anchoredProcessCwds.push(shared.realPathForComparison(rec.cwd)); }
817
- catch (_e) { /* malformed cwd — skip */ }
818
- }
819
- }
820
- } catch (e) { log('warn', `managed-spawn cwd anchor lookup failed: ${e.message}`); }
821
- try {
822
- const _kp = require('./keep-process-sweep');
823
- _anchoredProcessCwds.push(..._kp.getActiveKeepProcessCwds());
824
- } catch (e) { log('warn', `keep-processes cwd anchor lookup failed: ${e.message}`); }
825
-
826
772
  // Probe `git branch --show-current` for every worktree in chunks of 5.
827
773
  // Sequential probing was the dominant cost in the cleanup phase
828
774
  // (5–15s tick stall every 10 ticks at 50+ worktrees), but unbounded
@@ -838,10 +784,53 @@ async function runCleanup(config, verbose = false) {
838
784
  for (const [wtPath, branch] of pairs) branchMap.set(wtPath, branch);
839
785
  }
840
786
 
841
- for (const { dir, wtPath } of allDirs) {
787
+ // Refresh process protection after the asynchronous branch probes so a
788
+ // service started during that wait cannot lose its cwd at deletion.
789
+ const _anchoredProcessCwds = [];
790
+ let _anchorProbeFailed = false;
791
+ try {
792
+ const _ms = require('./managed-spawn');
793
+ for (const rec of _ms.listManagedSpecs()) {
794
+ if (rec && typeof rec.cwd === 'string' && rec.cwd.length > 0) {
795
+ try { _anchoredProcessCwds.push(shared.realPathForComparison(rec.cwd)); }
796
+ catch (_e) { /* malformed cwd — skip */ }
797
+ }
798
+ }
799
+ } catch (e) {
800
+ _anchorProbeFailed = true;
801
+ log('warn', `managed-spawn cwd anchor lookup failed: ${e.message}`);
802
+ }
803
+ try {
804
+ const _kp = require('./keep-process-sweep');
805
+ _anchoredProcessCwds.push(..._kp.getActiveKeepProcessCwds());
806
+ } catch (e) {
807
+ _anchorProbeFailed = true;
808
+ log('warn', `keep-processes cwd anchor lookup failed: ${e.message}`);
809
+ }
810
+ if (_anchorProbeFailed) {
811
+ log('warn', `cleanup merged worktrees for ${project.name || root}: process anchor state unavailable — fail-closed`);
812
+ continue;
813
+ }
814
+ let _cwdHolders;
815
+ try {
816
+ const finder = process.platform === 'win32'
817
+ ? shared.findProcessCwdHolders
818
+ : shared.findProcessesWithCwdInside;
819
+ _cwdHolders = finder(worktreeRoot, {
820
+ timeoutMs: process.platform === 'win32'
821
+ ? (config.engine?.statusProbeKillTimeoutMs ?? ENGINE_DEFAULTS.statusProbeKillTimeoutMs)
822
+ : (config.engine?.orphanHolderScanTimeoutMs ?? ENGINE_DEFAULTS.orphanHolderScanTimeoutMs),
823
+ throwOnFailure: true,
824
+ }) || [];
825
+ } catch (err) {
826
+ log('warn', `cleanup merged worktrees for ${project.name || root}: holder probe failed (${err.message}) — fail-closed`);
827
+ continue;
828
+ }
829
+
830
+ for (const { dir, wtPath, registration } of allDirs) {
842
831
 
843
832
  let shouldClean = false;
844
- let isProtected = false;
833
+ let isProtected = !!registration?.locked;
845
834
  const actualBranch = branchMap.get(wtPath) || '';
846
835
 
847
836
  // Check if this worktree's branch is merged/abandoned
@@ -862,6 +851,7 @@ async function runCleanup(config, verbose = false) {
862
851
  return worktreeMatchesBranch(dirLower, d.meta.branch, actualBranch);
863
852
  });
864
853
  if (isReferenced) isProtected = true;
854
+ if (!isProtected && shared.isWorktreePathLive(wtPath)) isProtected = true;
865
855
 
866
856
  // P-e0b4f7a5 — protect worktrees whose branch matches a work item in
867
857
  // the phantom-completion retry state. The dispatch may have already
@@ -880,7 +870,6 @@ async function runCleanup(config, verbose = false) {
880
870
  // W-mp73ya3e000me6c5 — pool-membership protection.
881
871
  const _normWt = _normalizePoolPath ? _normalizePoolPath(wtPath) : wtPath;
882
872
  const _isActiveBorrowed = _activeBorrowedPaths.has(_normWt);
883
- const _isIdlePool = _idlePaths.has(_normWt);
884
873
  if (_isActiveBorrowed) {
885
874
  // Borrowed by a live dispatch — full protection (overrides any
886
875
  // earlier shouldClean from merged-branch match too: the borrow
@@ -893,7 +882,7 @@ async function runCleanup(config, verbose = false) {
893
882
  // Long-running process cwd anchor protection.
894
883
  // Worktrees backing live managed_spawn or keep_processes cwds must outlive the
895
884
  // originating dispatch.
896
- // Overrides merged-branch and age sweeps because the cwd record is
885
+ // Overrides merged-branch cleanup because the cwd record is
897
886
  // the authoritative signal that something live still uses the dir.
898
887
  if (_anchoredProcessCwds.length > 0) {
899
888
  for (const cwd of _anchoredProcessCwds) {
@@ -910,17 +899,14 @@ async function runCleanup(config, verbose = false) {
910
899
  }
911
900
  }
912
901
 
913
- // Also clean worktrees older than 2 hours with no active dispatch referencing them
914
- let mtime = Date.now();
915
- if (!shouldClean) {
916
- try {
917
- const stat = fs.statSync(wtPath);
918
- mtime = stat.mtimeMs;
919
- const ageMs = Date.now() - mtime;
920
- if (ageMs > 7200000 && !isReferenced && !isProtected && !_isIdlePool) { // 2 hours — P-e0b4f7a5: phantom-protected worktrees survive the age sweep too. W-mp73ya3e000me6c5: idle pool entries also survive (TTL eviction handled by worktree-pool.pruneStale).
921
- shouldClean = true;
922
- }
923
- } catch { /* optional */ }
902
+ if (!isProtected && _cwdHolders.some((holder) => {
903
+ if (!holder?.cwd) return true;
904
+ try { return shared.isPathInsideOrEqual(holder.cwd, wtPath); }
905
+ catch { return true; }
906
+ })) {
907
+ isProtected = true;
908
+ shouldClean = false;
909
+ if (verbose) console.log(` Skipping worktree ${dir}: process cwd holder`);
924
910
  }
925
911
 
926
912
  // Skip worktrees for active shared-branch plans.
@@ -937,19 +923,7 @@ async function runCleanup(config, verbose = false) {
937
923
  }
938
924
  }
939
925
 
940
- wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected, actualBranch, matchedMergedBranch });
941
- }
942
-
943
- // Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
944
- const surviving = wtEntries.filter(e => !e.shouldClean && !e.isProtected);
945
- if (surviving.length + wtEntries.filter(e => e.isProtected).length > MAX_WORKTREES) {
946
- // Sort oldest first
947
- surviving.sort((a, b) => a.mtime - b.mtime);
948
- const excess = surviving.length + wtEntries.filter(e => e.isProtected).length - MAX_WORKTREES;
949
- for (let i = 0; i < Math.min(excess, surviving.length); i++) {
950
- surviving[i].shouldClean = true;
951
- if (verbose) console.log(` Marking worktree ${surviving[i].dir} for cap cleanup (${MAX_WORKTREES} max)`);
952
- }
926
+ wtEntries.push({ dir, wtPath, shouldClean, isProtected, actualBranch, matchedMergedBranch });
953
927
  }
954
928
 
955
929
  // Remove all marked worktrees
@@ -966,9 +940,32 @@ async function runCleanup(config, verbose = false) {
966
940
  freshMergedPrByBranch.set(sanitizeBranch(normalizeLocalBranchName(pr.branch)).toLowerCase(), pr);
967
941
  }
968
942
  }
943
+ let freshRegisteredByPath;
944
+ try {
945
+ const raw = String(shared.execSilent('git --no-optional-locks worktree list --porcelain', {
946
+ cwd: root, timeout: 15000, windowsHide: true,
947
+ }) || '');
948
+ freshRegisteredByPath = new Map(
949
+ shared.parseWorktreePorcelain(raw)
950
+ .filter(item => item?.path)
951
+ .map(item => [worktreePool()._normalizePath(item.path), item]),
952
+ );
953
+ } catch (err) {
954
+ log('warn', `cleanup merged worktrees for ${project.name || root}: final registry check failed (${err.message}) — fail-closed`);
955
+ continue;
956
+ }
969
957
 
970
958
  for (const entry of wtEntries) {
971
959
  if (entry.shouldClean) {
960
+ const normalizedEntryPath = worktreePool()._normalizePath(entry.wtPath);
961
+ const freshRegistration = freshRegisteredByPath.get(normalizedEntryPath);
962
+ if (!freshRegistration
963
+ || freshRegistration.locked
964
+ || shared.isWorktreePathLive(entry.wtPath)
965
+ || !shared.hasWorktreeOwnerMarker(entry.wtPath)
966
+ || shared.hasProtectedQuarantineMarker(entry.wtPath)) {
967
+ continue;
968
+ }
972
969
  // Verify the branch is still merged/closed — skip if PR was reopened since initial check
973
970
  const entryDirLower = entry.dir.toLowerCase();
974
971
  let stillMerged = false;
@@ -978,25 +975,18 @@ async function runCleanup(config, verbose = false) {
978
975
  break;
979
976
  }
980
977
  }
981
- // If originally marked due to merged branch but PR was reopened, skip deletion
982
978
  if (!stillMerged) {
983
- // Check if it was marked for age/cap cleanup (not branch-based) — those are still valid
984
- const wasMarkedByBranch = [...mergedBranches].some(branch => worktreeMatchesBranch(entryDirLower, branch, entry.actualBranch));
985
- if (wasMarkedByBranch) {
986
- if (verbose) console.log(` Skipping worktree ${entry.dir}: PR was reopened since initial check`);
987
- log('info', `Worktree deletion skipped — PR reopened: ${entry.dir}`);
988
- continue;
989
- }
979
+ if (verbose) console.log(` Skipping worktree ${entry.dir}: PR was reopened since initial check`);
980
+ log('info', `Worktree deletion skipped PR reopened: ${entry.dir}`);
981
+ continue;
990
982
  }
991
983
 
992
984
  if (_attemptedWorktreePaths.has(entry.wtPath)) continue;
993
985
  _attemptedWorktreePaths.add(entry.wtPath);
994
- // Kill any process still running in this worktree before removal
995
- _killProcessInWorktree(entry.dir, activeProcesses, activeDispatchIds);
996
986
  if (shared.removeWorktree(entry.wtPath, root, worktreeRoot)) {
997
987
  cleaned.worktrees++;
998
- // W-mp73ya3e000me6c5 — keep the pool file in sync when an idle
999
- // pool entry is reaped by the cap sweep (or any other reason).
988
+ // W-mp73ya3e000me6c5 — keep the pool file in sync when terminal
989
+ // PR cleanup removes an entry.
1000
990
  try { worktreePool().evictEntry(entry.wtPath, 'cleanup-removed'); } catch (_e) { /* optional */ }
1001
991
  const mergedPr = entry.matchedMergedBranch
1002
992
  ? freshMergedPrByBranch.get(sanitizeBranch(normalizeLocalBranchName(entry.matchedMergedBranch)).toLowerCase())
@@ -1043,6 +1033,29 @@ async function runCleanup(config, verbose = false) {
1043
1033
 
1044
1034
  // Clean individual orphaned processes — no matching active dispatch
1045
1035
  const activeIds = new Set((dispatch.active || []).map(d => d.id));
1036
+ cleaned.stalePidFiles = 0;
1037
+ shared.forEachPidFile((pidFilePath, fileName, layout) => {
1038
+ let stat;
1039
+ try { stat = fs.statSync(pidFilePath); } catch { return; }
1040
+ if (stat.mtimeMs >= oneHourAgo) return;
1041
+ const pidFileName = fileName.replace(/^pid-/, '').replace(/\.pid$/, '');
1042
+ if ([...activeIds].some(id => pidFileName.includes(String(id).slice(-8)))) return;
1043
+ let pid;
1044
+ try { pid = Number(fs.readFileSync(pidFilePath, 'utf8').trim()); } catch { return; }
1045
+ if (activePids.has(pid)) return;
1046
+ if (Number.isInteger(pid) && pid > 0 && shared.isProcessCommandLineMatchingAgent(pid)) {
1047
+ try {
1048
+ shared.killImmediate({ pid });
1049
+ cleaned.zombies++;
1050
+ } catch { /* process may already be gone */ }
1051
+ }
1052
+ if (layout === 'dispatch-dir') {
1053
+ try { shared.removeDispatchTmpDir(path.dirname(pidFilePath)); } catch {}
1054
+ } else {
1055
+ try { fs.unlinkSync(pidFilePath); } catch {}
1056
+ }
1057
+ cleaned.stalePidFiles++;
1058
+ });
1046
1059
  for (const [id, info] of activeProcesses.entries()) {
1047
1060
  if (!activeIds.has(id)) {
1048
1061
  shared.killImmediate(info.proc);
@@ -1186,8 +1199,9 @@ async function runCleanup(config, verbose = false) {
1186
1199
 
1187
1200
  const _orphanTemp = cleaned.orphanTempAgentDirs || 0;
1188
1201
  const _orphanWt = cleaned.orphanWorktreeDirs || 0;
1189
- if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches + (cleaned.nestedWorktrees || 0) + _orphanTemp + _orphanWt > 0) {
1190
- log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches, ${cleaned.nestedWorktrees || 0} nested worktrees flagged, ${_orphanTemp} orphan temp dirs, ${_orphanWt} orphan worktree dirs`);
1202
+ const _reconcileFailed = cleaned.worktreeReconcileFailed || 0;
1203
+ if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches + (cleaned.nestedWorktrees || 0) + _orphanTemp + _orphanWt + _reconcileFailed + cleaned.orphanManagedLogs > 0) {
1204
+ log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches, ${cleaned.nestedWorktrees || 0} nested worktrees flagged, ${_orphanTemp} orphan temp dirs, ${_orphanWt} orphan worktree dirs, ${cleaned.orphanManagedLogs} orphan managed logs, ${_reconcileFailed} worktree reconcile failures`);
1191
1205
  }
1192
1206
 
1193
1207
  // 8. Clean swept KB files older than 7 days
@@ -1577,6 +1591,37 @@ async function runCleanup(config, verbose = false) {
1577
1591
  }
1578
1592
  } catch (e) { log('warn', `pruneCrashDiagnosticsReports: ${e.message}`); }
1579
1593
 
1594
+ // 19. Review-learning retention (P-1ed2fb5f). Unresolved candidates expire
1595
+ // after 180 days; compact lifecycle events are capped at one year AND 50000
1596
+ // rows. Deliberately decoupled from KB file TTL — active procedural lessons
1597
+ // persist until superseded or retracted, so this never touches active rows.
1598
+ cleaned.expiredReviewCandidates = 0;
1599
+ cleaned.prunedLifecycleEvents = 0;
1600
+ try {
1601
+ const memoryStore = require('./memory-store');
1602
+ cleaned.expiredReviewCandidates = memoryStore.expireStaleReviewLearningCandidates();
1603
+ cleaned.prunedLifecycleEvents = memoryStore.pruneMemoryLifecycleEvents();
1604
+ if (cleaned.expiredReviewCandidates > 0 || cleaned.prunedLifecycleEvents > 0) {
1605
+ log('info', `cleanup: expired ${cleaned.expiredReviewCandidates} review candidate(s), pruned ${cleaned.prunedLifecycleEvents} lifecycle event(s)`);
1606
+ }
1607
+ } catch (e) { log('warn', `review-learning retention: ${e.message}`); }
1608
+
1609
+ // 20. Recovery refs outlive the 24-hour quarantine-directory window so an
1610
+ // operator can still recover preserved commits/WIP after directory cleanup.
1611
+ // Bound that independent recovery window at 10 days.
1612
+ cleaned.quarantineRefs = 0;
1613
+ try {
1614
+ const { pruneExpiredQuarantineRefs } = require('./quarantine-refs');
1615
+ const result = await pruneExpiredQuarantineRefs(projects);
1616
+ cleaned.quarantineRefs = result.removed;
1617
+ if (result.removed > 0) {
1618
+ log('info', `cleanup: pruned ${result.removed} quarantine recovery ref(s) older than 10 days`);
1619
+ }
1620
+ for (const failure of result.errors) {
1621
+ log('warn', `quarantine-ref retention (${failure.project || 'unknown project'}): ${failure.error}`);
1622
+ }
1623
+ } catch (e) { log('warn', `quarantine-ref retention: ${e.message}`); }
1624
+
1580
1625
  return cleaned;
1581
1626
  }
1582
1627
 
@@ -1615,13 +1660,18 @@ function scrubStaleMetrics() {
1615
1660
  *
1616
1661
  * Both pruners share the SAME `dispatchSnap` from `queries.getDispatch()`
1617
1662
  * so live work isn't yanked between scans. Returns aggregate stats for
1618
- * tick-level logging.
1663
+ * tick-level logging. When holder-reap retries are deferred, `stats.settled`
1664
+ * resolves with the same object after its final counters are refreshed.
1619
1665
  */
1620
1666
  function runPeriodicWorktreeSweep(config) {
1621
1667
  const worktreeGc = require('./worktree-gc');
1622
1668
  const allProjects = getProjects(config);
1623
1669
  // PL-live-checkout-reliability-hardening — EXCLUDE live-checkout projects from
1624
- // the worktree GC entirely. The three pruners derive their targets from
1670
+ // registry-derived destructive pruners. Filesystem reconciliation below still
1671
+ // enumerates every configured repo: hybrid projects create worktrees for typed
1672
+ // tasks, pure-live projects may retain residue from a prior mode, and every repo
1673
+ // sharing a root must contribute registrations before any directory is deleted.
1674
+ // The three legacy pruners derive their targets from
1625
1675
  // `git worktree list --porcelain`, which for a live project returns the
1626
1676
  // OPERATOR'S OWN primary checkout (and any secondary operator worktrees). The
1627
1677
  // engine never creates a managed worktree in live mode, so there is nothing
@@ -1636,8 +1686,15 @@ function runPeriodicWorktreeSweep(config) {
1636
1686
  try { return !shared.isLiveCheckoutProject(p); } catch { return false; } // W-mquoe8fu000w9383: fail-CLOSED — exclude from GC when checkout mode cannot be determined
1637
1687
  });
1638
1688
  const liveSkipped = allProjects.length - projects.length;
1639
- if (projects.length === 0) {
1640
- return { scanned: 0, kept: 0, evicted: 0, failed: 0, outOfRootEvicted: 0, prunedRegistry: 0, missingDirReclaimed: 0, missingDirSkippedLive: 0, liveProjectsSkipped: liveSkipped };
1689
+ if (allProjects.length === 0) {
1690
+ return {
1691
+ scanned: 0, kept: 0, evicted: 0, failed: 0,
1692
+ removed: 0, skipped: 0, preserved: 0,
1693
+ outOfRootEvicted: 0, prunedRegistry: 0,
1694
+ missingDirReclaimed: 0, missingDirSkippedLive: 0,
1695
+ reconciledStaleDirs: 0, reconciledScanned: 0,
1696
+ liveProjectsSkipped: liveSkipped,
1697
+ };
1641
1698
  }
1642
1699
  const dispatchSnap = getDispatch();
1643
1700
  const worktreeRootRel = config?.engine?.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot;
@@ -1645,6 +1702,10 @@ function runPeriodicWorktreeSweep(config) {
1645
1702
 
1646
1703
  let scanned = 0, kept = 0, evicted = 0, failed = 0, outOfRootEvicted = 0, prunedRegistry = 0;
1647
1704
  let missingDirReclaimed = 0, missingDirSkippedLive = 0;
1705
+ let reconciledStaleDirs = 0, reconciledScanned = 0;
1706
+ let removed = 0, skipped = 0, preserved = 0;
1707
+ let r0 = null, r1 = null, r2 = null, r3 = null;
1708
+ const deferredRetries = [];
1648
1709
  const _writeToInbox = (a, s, c) => { try { return shared.writeToInbox(a, s, c); } catch (_e) { return false; } };
1649
1710
 
1650
1711
  // P-d8f1a3c6 — shell `git worktree list --porcelain` at most once per project
@@ -1655,7 +1716,7 @@ function runPeriodicWorktreeSweep(config) {
1655
1716
  // consumer's own self-shelling path. `pruneOrphanWorktrees` (r1, fs-readdir
1656
1717
  // based) never lists and is unaffected.
1657
1718
  const parsedTreesByProject = new Map();
1658
- for (const project of projects) {
1719
+ for (const project of allProjects) {
1659
1720
  if (!project || !project.localPath) continue;
1660
1721
  let rootDir;
1661
1722
  try { rootDir = path.resolve(String(project.localPath)); } catch { continue; }
@@ -1674,44 +1735,103 @@ function runPeriodicWorktreeSweep(config) {
1674
1735
  }
1675
1736
  }
1676
1737
 
1738
+ // Filesystem-authoritative reconciliation: Git registry sweeps cannot see
1739
+ // directories left behind after their registration was pruned. Reconcile
1740
+ // those stale, positively-owned dirs before the dispatch-oriented pruners.
1677
1741
  try {
1678
- const r1 = worktreeGc.pruneOrphanWorktrees({
1679
- projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
1680
- });
1681
- scanned += r1.scanned || 0;
1682
- kept += r1.kept || 0;
1683
- evicted += r1.evicted || 0;
1684
- failed += r1.failed || 0;
1685
- } catch (e) { log('warn', `worktree-gc periodic in-root: ${e.message}`); }
1686
-
1687
- try {
1688
- const r2 = worktreeGc.pruneOrphanWorktreesFromGitRegistry({
1689
- projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
1690
- parsedTreesByProject,
1742
+ r0 = worktreeGc.reconcileStaleWorktreeDirectories({
1743
+ projects: allProjects, dispatchSnap, worktreeRootRel, log: _log, config,
1744
+ parsedTreesByProject, writeToInbox: _writeToInbox,
1691
1745
  });
1692
- scanned += r2.scanned || 0;
1693
- kept += r2.kept || 0;
1694
- outOfRootEvicted += r2.evicted || 0;
1695
- failed += r2.failed || 0;
1696
- prunedRegistry += r2.prunedRegistry || 0;
1697
- } catch (e) { log('warn', `worktree-gc periodic out-of-root: ${e.message}`); }
1746
+ reconciledScanned = r0.scanned || 0;
1747
+ reconciledStaleDirs += r0.removed || 0;
1748
+ failed += r0.failed || 0;
1749
+ removed += r0.removed || 0;
1750
+ } catch (e) {
1751
+ failed++;
1752
+ log('warn', `worktree-gc periodic filesystem reconcile: ${e.message}`);
1753
+ }
1698
1754
 
1699
1755
  // W-mqifblkf00149df5 — reclaim locked-`initializing` worktree entries whose
1700
- // backing dir is MISSING. Neither pruner above touches these: the in-root
1756
+ // backing dir is MISSING. Neither sibling pruner touches these: the in-root
1701
1757
  // scanner walks the filesystem (the dir is gone, so it's invisible) and the
1702
1758
  // out-of-root scanner deliberately skips dirs inside worktreeRoot and only
1703
1759
  // runs a plain `prune` that SKIPS locked entries. Without this, an
1704
1760
  // interrupted `git worktree add` bricks the branch until a human intervenes.
1761
+ // Run this BEFORE either removal pruner mutates the registry represented by
1762
+ // parsedTreesByProject. Otherwise a successful in-root eviction turns its
1763
+ // once-present entry into a stale "missing" candidate and produces a false
1764
+ // remove -f -f failure later in the same sweep.
1705
1765
  try {
1706
- const r3 = worktreeGc.reclaimMissingDirWorktrees({
1766
+ r3 = worktreeGc.reclaimMissingDirWorktrees({
1707
1767
  projects, log: _log, config, parsedTreesByProject,
1708
1768
  });
1769
+ scanned += r3.scanned || 0;
1709
1770
  missingDirReclaimed += r3.reclaimed || 0;
1710
1771
  missingDirSkippedLive += r3.skippedLive || 0;
1711
1772
  failed += r3.failed || 0;
1773
+ removed += r3.removed ?? r3.reclaimed ?? 0;
1774
+ skipped += r3.skipped ?? r3.skippedLive ?? 0;
1775
+ preserved += r3.preserved || 0;
1712
1776
  } catch (e) { log('warn', `worktree-gc periodic missing-dir reclaim: ${e.message}`); }
1713
1777
 
1714
- return { scanned, kept, evicted, failed, outOfRootEvicted, prunedRegistry, missingDirReclaimed, missingDirSkippedLive, liveProjectsSkipped: liveSkipped };
1778
+ if (r0) {
1779
+ try {
1780
+ r1 = worktreeGc.pruneOrphanWorktrees({
1781
+ projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
1782
+ deferredRetries,
1783
+ protectedPaths: r0.protectedPaths,
1784
+ failedRoots: r0.failedRoots,
1785
+ });
1786
+ scanned += r1.scanned || 0;
1787
+ kept += r1.kept || 0;
1788
+ evicted += r1.evicted || 0;
1789
+ failed += r1.failed || 0;
1790
+ removed += r1.removed ?? r1.evicted ?? 0;
1791
+ skipped += r1.skipped || 0;
1792
+ preserved += r1.preserved || 0;
1793
+ } catch (e) { log('warn', `worktree-gc periodic in-root: ${e.message}`); }
1794
+ } else {
1795
+ log('warn', 'worktree-gc periodic in-root skipped because filesystem reconciliation did not complete');
1796
+ }
1797
+
1798
+ try {
1799
+ r2 = worktreeGc.pruneOrphanWorktreesFromGitRegistry({
1800
+ projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
1801
+ parsedTreesByProject, deferredRetries,
1802
+ });
1803
+ scanned += r2.scanned || 0;
1804
+ kept += r2.kept || 0;
1805
+ outOfRootEvicted += r2.evicted || 0;
1806
+ failed += r2.failed || 0;
1807
+ prunedRegistry += r2.prunedRegistry || 0;
1808
+ removed += r2.removed ?? r2.evicted ?? 0;
1809
+ skipped += r2.skipped || 0;
1810
+ preserved += r2.preserved || 0;
1811
+ } catch (e) { log('warn', `worktree-gc periodic out-of-root: ${e.message}`); }
1812
+
1813
+ const stats = {
1814
+ scanned, kept, evicted, failed,
1815
+ removed, skipped, preserved,
1816
+ outOfRootEvicted, prunedRegistry,
1817
+ missingDirReclaimed, missingDirSkippedLive,
1818
+ reconciledStaleDirs, reconciledScanned,
1819
+ liveProjectsSkipped: liveSkipped,
1820
+ };
1821
+ if (deferredRetries.length > 0) {
1822
+ const settled = Promise.allSettled(deferredRetries).then(() => {
1823
+ stats.evicted = r1?.evicted || 0;
1824
+ stats.outOfRootEvicted = r2?.evicted || 0;
1825
+ stats.failed = (r0?.failed || 0) + (r3?.failed || 0) + (r1?.failed || 0) + (r2?.failed || 0);
1826
+ stats.removed = (r0?.removed || 0)
1827
+ + (r3?.removed ?? r3?.reclaimed ?? 0)
1828
+ + (r1?.removed ?? r1?.evicted ?? 0)
1829
+ + (r2?.removed ?? r2?.evicted ?? 0);
1830
+ return stats;
1831
+ });
1832
+ Object.defineProperty(stats, 'settled', { value: settled, enumerable: false });
1833
+ }
1834
+ return stats;
1715
1835
  }
1716
1836
 
1717
1837
  // ─── Exports ─────────────────────────────────────────────────────────────────
@@ -1729,4 +1849,6 @@ module.exports = {
1729
1849
  collectPhantomBranchesForProject, // P-e0b4f7a5 — exported for testing
1730
1850
  reapAgentScratch, // exported for testing
1731
1851
  reapKnowledgeScratch, // exported for testing
1852
+ pruneStaleEngineTmpFiles, // exported for testing
1853
+ pruneOrphanManagedLogs, // exported for testing
1732
1854
  };