@yemi33/minions 0.1.2232 → 0.1.2234

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.
@@ -347,6 +347,162 @@ function _postReapRetry(wtPath, gitRoot, parentDir, resolvedPath, _removeWorktre
347
347
  return false;
348
348
  }
349
349
 
350
+ /**
351
+ * P-c7e2b405 — shared "reap-then-remove" core for the THREE worktree pruners
352
+ * (gcDispatchWorktreeIfOrphan, pruneOrphanWorktrees in-root,
353
+ * pruneOrphanWorktreesFromGitRegistry out-of-root). It folds the previously
354
+ * copy-pasted slow-retry gate / ownership-marker gate / remove+escalate+
355
+ * post-reap-retry tail into one decision surface so the three sites cannot
356
+ * drift apart.
357
+ *
358
+ * Encapsulates, in order:
359
+ * (1) slow-retry gate — only when `slowRetryMs` is a number (the two
360
+ * sweeps pass it; the dispatch-end caller leaves it null so a fresh
361
+ * worktree is never slow-gated). Skip → `{ outcome:'skip',
362
+ * reason:'slow-retry' }`.
363
+ * (2) ownership-marker gate — only when `requireOwnerMarker:true`
364
+ * (fail-open: a read error counts as "no marker"). No marker → KEEP the
365
+ * foreign/hand-made worktree, emit the debug log, and never escalate.
366
+ * Skip → `{ outcome:'skip', reason:'no-marker' }`.
367
+ * (3) pre-remove reaper hook — `reapHolders(path,{excludeDispatchId,
368
+ * ownerDispatchId})` when injected (dispatch-end only). Best-effort;
369
+ * a throw never blocks the removal that follows.
370
+ * (4) remove — the injected `removeWorktree(path,gitRoot,parentDir,rmOpts)`.
371
+ * We do NOT re-implement its EBUSY/_retryFsOp backoff; removeWorktree
372
+ * owns that loop. The live-guard ALSO stays authoritative inside
373
+ * shared.removeWorktree + engine._reapWorktreeHolders (both fail-open) —
374
+ * it is never re-implemented here.
375
+ * (5) outcome accounting — on truthy: bump `evicted` on each `stats` object,
376
+ * _markStuckSuccess, optional success metric, info log. On false/throw:
377
+ * bump `failed`, _maybeEscalateStuck (with each site's exact
378
+ * reason/findProcessesWithCwdInside/killImmediate/config/writeToInbox
379
+ * args), warn log (gated on alreadyEscalated + suppression), and the
380
+ * `if (reapedPids.length) _postReapRetry(...)` tail when
381
+ * `enablePostReapRetry`.
382
+ *
383
+ * Returns `{ outcome, removed, reason, escalated, stuckEscalated, reapedPids }`
384
+ * matching gcDispatchWorktreeIfOrphan's existing contract. When `stats`
385
+ * objects are supplied the helper mutates their evicted/failed/kept counters
386
+ * directly (the sweeps); the dispatch-end caller passes none and maps the
387
+ * return value instead.
388
+ */
389
+ function reapAndRemoveWorktree(opts) {
390
+ const {
391
+ worktreePath,
392
+ gitRoot,
393
+ parentDir,
394
+ resolved: resolvedIn = null,
395
+ rmOpts,
396
+ // gates
397
+ slowRetryMs = null,
398
+ requireOwnerMarker = false,
399
+ // pre-remove reaper (dispatch-end only)
400
+ reapHolders = null,
401
+ excludeDispatchId = null,
402
+ ownerDispatchId = null,
403
+ // escalation flavor
404
+ escalateReason = null, // 'orphan-sweep' for the two sweeps; null for dispatch-end
405
+ findProcessesWithCwdInside = null,
406
+ killImmediate = null,
407
+ config = null,
408
+ writeToInbox = null,
409
+ sleepSyncFn = null,
410
+ // accounting + logging
411
+ stats = [],
412
+ successReason = 'orphan',
413
+ successMetric = null,
414
+ recheckSuppressedAfterEscalate = false,
415
+ enablePostReapRetry = false,
416
+ onRemovedLog = null,
417
+ onFailLog = null,
418
+ onMarkerSkipLog = null,
419
+ // injected deps (real-impl defaults)
420
+ removeWorktree = null,
421
+ hasOwnerMarker = null,
422
+ log = _noopLog,
423
+ } = opts || {};
424
+
425
+ const _removeFn = typeof removeWorktree === 'function' ? removeWorktree : shared.removeWorktree;
426
+ const _hasMarker = typeof hasOwnerMarker === 'function' ? hasOwnerMarker : shared.hasWorktreeOwnerMarker;
427
+ const resolved = resolvedIn || (() => { try { return path.resolve(worktreePath); } catch { return worktreePath; } })();
428
+ const _bumpStats = (key) => { for (const s of stats) { if (s && typeof s[key] === 'number') s[key]++; } };
429
+ const _idle = () => ({ outcome: 'skip', removed: false, escalated: false, stuckEscalated: false, reapedPids: [] });
430
+
431
+ // (1) slow-retry gate — sweeps only.
432
+ if (typeof slowRetryMs === 'number') {
433
+ if (!_shouldSlowRetry(resolved, slowRetryMs)) {
434
+ _bumpStats('kept');
435
+ return { ..._idle(), reason: 'slow-retry' };
436
+ }
437
+ }
438
+
439
+ // (2) ownership-marker gate — fail-open (no marker ⇒ KEEP foreign/hand-made).
440
+ if (requireOwnerMarker) {
441
+ let owned = false;
442
+ try { owned = !!_hasMarker(worktreePath); }
443
+ catch (_e) { owned = false; }
444
+ if (!owned) {
445
+ _bumpStats('kept');
446
+ if (typeof onMarkerSkipLog === 'function') log('debug', onMarkerSkipLog());
447
+ return { ..._idle(), reason: 'no-marker' };
448
+ }
449
+ }
450
+
451
+ // Capture suppression state BEFORE the remove attempt (sweep semantics).
452
+ const suppressedBefore = _isStuckPathSuppressed(resolved);
453
+
454
+ // (3) pre-remove holder reap (dispatch-end injects engine._reapWorktreeHolders).
455
+ if (typeof reapHolders === 'function') {
456
+ try {
457
+ reapHolders(worktreePath, { excludeDispatchId, ownerDispatchId });
458
+ } catch (reapErr) {
459
+ log('warn', `worktree-gc: pre-remove reap threw for ${worktreePath}: ${reapErr && reapErr.message}`);
460
+ }
461
+ }
462
+
463
+ const _escalateOpts = {
464
+ config, writeToInbox,
465
+ reason: escalateReason,
466
+ findProcessesWithCwdInside, killImmediate,
467
+ };
468
+ const _shouldWarn = (escResult) => {
469
+ const suppressed = recheckSuppressedAfterEscalate
470
+ ? _isStuckPathSuppressed(resolved)
471
+ : suppressedBefore;
472
+ return !escResult.alreadyEscalated && !suppressed;
473
+ };
474
+ const _postReapOpts = { writeToInbox, sleepSyncFn };
475
+
476
+ // (4)+(5) remove + accounting. removeWorktree owns its own backoff loop.
477
+ try {
478
+ const removed = _removeFn(worktreePath, gitRoot, parentDir, rmOpts);
479
+ if (removed) {
480
+ _bumpStats('evicted');
481
+ _markStuckSuccess(resolved, { writeToInbox });
482
+ if (successMetric) { try { shared.bumpWorktreeGcMetric(successMetric); } catch { /* optional */ } }
483
+ if (typeof onRemovedLog === 'function') log('info', onRemovedLog());
484
+ return { outcome: 'gc', removed: true, reason: successReason, escalated: false, stuckEscalated: false, reapedPids: [] };
485
+ }
486
+ _bumpStats('failed');
487
+ const escResult = _maybeEscalateStuck(resolved, 'remove returned false', _escalateOpts);
488
+ if (_shouldWarn(escResult) && typeof onFailLog === 'function') log('warn', onFailLog('false'));
489
+ const reapedPids = escResult.reapedPids || [];
490
+ if (enablePostReapRetry && reapedPids.length > 0) {
491
+ _postReapRetry(worktreePath, gitRoot, parentDir, resolved, _removeFn, _postReapOpts, stats[0] || null, stats[1] || null, log, reapedPids);
492
+ }
493
+ return { outcome: 'gc-failed', removed: false, reason: 'remove-failed', escalated: escResult.escalated, stuckEscalated: escResult.escalated, reapedPids };
494
+ } catch (rmErr) {
495
+ _bumpStats('failed');
496
+ const escResult = _maybeEscalateStuck(resolved, rmErr && rmErr.message, _escalateOpts);
497
+ if (_shouldWarn(escResult) && typeof onFailLog === 'function') log('warn', onFailLog('threw', rmErr && rmErr.message));
498
+ const reapedPids = escResult.reapedPids || [];
499
+ if (enablePostReapRetry && reapedPids.length > 0) {
500
+ _postReapRetry(worktreePath, gitRoot, parentDir, resolved, _removeFn, _postReapOpts, stats[0] || null, stats[1] || null, log, reapedPids);
501
+ }
502
+ return { outcome: 'gc-failed', removed: false, reason: 'remove-threw', escalated: escResult.escalated, stuckEscalated: escResult.escalated, reapedPids };
503
+ }
504
+ }
505
+
350
506
  /**
351
507
  * Decide whether a dispatch-end worktree should be GC'd.
352
508
  *
@@ -494,41 +650,34 @@ function gcDispatchWorktreeIfOrphan(opts) {
494
650
  if (!gitRoot || !worktreeRoot) {
495
651
  return { outcome: 'skip', reason: 'no-git-root', removed: false };
496
652
  }
497
- const _removeFn = typeof removeWorktree === 'function' ? removeWorktree : shared.removeWorktree;
498
- const _rmOpts = excludeDispatchId ? { excludeDispatchId } : undefined;
499
- const resolved = (() => { try { return path.resolve(worktreePath); } catch { return worktreePath; } })();
500
- // W-mqinlicl §6 — reap the ending dispatch's own orphaned descendant tree
501
- // BEFORE attempting removal. The owning dispatch is still 'active' at this
502
- // point, so it is passed as `ownerDispatchId` (terminal-owner discovery would
503
- // miss it) and as `excludeDispatchId` so the reaper's live-guard ignores this
504
- // dispatch's own row. Failure here must never block the removal that follows.
505
- if (typeof reapHolders === 'function') {
506
- try {
507
- reapHolders(worktreePath, { excludeDispatchId, ownerDispatchId: excludeDispatchId });
508
- } catch (reapErr) {
509
- log('warn', `worktree-gc: pre-remove reap threw for ${worktreePath}: ${reapErr && reapErr.message}`);
510
- }
511
- }
512
- try {
513
- const removed = _removeFn(worktreePath, gitRoot, worktreeRoot, _rmOpts);
514
- if (removed) {
515
- _markStuckSuccess(resolved, { writeToInbox });
516
- log('info', `worktree-gc: dispatch-end removed ${path.basename(worktreePath)}`);
517
- return { outcome: 'gc', reason: decision.reason, removed: true };
518
- }
519
- // W-mq5o6bvy000x7191 (Layer 4): track stuck dirs even on soft-false returns.
520
- const { escalated, alreadyEscalated } = _maybeEscalateStuck(resolved, 'remove returned false', { config, writeToInbox });
521
- if (!alreadyEscalated && !_isStuckPathSuppressed(resolved)) {
522
- log('warn', `worktree-gc: dispatch-end remove returned false for ${worktreePath}`);
523
- }
524
- return { outcome: 'gc-failed', reason: 'remove-failed', removed: false, stuckEscalated: escalated };
525
- } catch (gcErr) {
526
- const { escalated, alreadyEscalated } = _maybeEscalateStuck(resolved, gcErr && gcErr.message, { config, writeToInbox });
527
- if (!alreadyEscalated && !_isStuckPathSuppressed(resolved)) {
528
- log('warn', `worktree-gc: dispatch-end remove threw for ${worktreePath}: ${gcErr.message}`);
529
- }
530
- return { outcome: 'gc-failed', reason: 'remove-threw', removed: false, stuckEscalated: escalated };
653
+ // P-c7e2b405 — dispatch-end usage of the shared reap-then-remove core:
654
+ // requireOwnerMarker:false (a dispatch GCs its own worktree regardless of
655
+ // marker), no slow-retry gate (slowRetryMs left null — a fresh worktree must
656
+ // never be slow-gated), pre-remove reapHolders, and excludeDispatchId threaded
657
+ // both as the removeWorktree rmOpt and as the reaper's owner/exclude id.
658
+ const r = reapAndRemoveWorktree({
659
+ worktreePath,
660
+ gitRoot,
661
+ parentDir: worktreeRoot,
662
+ rmOpts: excludeDispatchId ? { excludeDispatchId } : undefined,
663
+ reapHolders,
664
+ excludeDispatchId,
665
+ ownerDispatchId: excludeDispatchId,
666
+ config,
667
+ writeToInbox,
668
+ removeWorktree,
669
+ log,
670
+ successReason: decision.reason,
671
+ recheckSuppressedAfterEscalate: true,
672
+ onRemovedLog: () => `worktree-gc: dispatch-end removed ${path.basename(worktreePath)}`,
673
+ onFailLog: (kind, errMsg) => kind === 'threw'
674
+ ? `worktree-gc: dispatch-end remove threw for ${worktreePath}: ${errMsg}`
675
+ : `worktree-gc: dispatch-end remove returned false for ${worktreePath}`,
676
+ });
677
+ if (r.outcome === 'gc') {
678
+ return { outcome: 'gc', reason: r.reason, removed: true };
531
679
  }
680
+ return { outcome: 'gc-failed', reason: r.reason, removed: false, stuckEscalated: r.stuckEscalated };
532
681
  }
533
682
 
534
683
  /**
@@ -570,6 +719,11 @@ function pruneOrphanWorktrees(opts) {
570
719
  const _buildWorktreeDirName = typeof opts.buildWorktreeDirName === 'function'
571
720
  ? opts.buildWorktreeDirName
572
721
  : shared.buildWorktreeDirName;
722
+ // P-c7e2b405 — in-root pruner now honors the ownership marker identically to
723
+ // the out-of-root pruner (was the one site that didn't gate on it).
724
+ const _hasOwnerMarker = typeof opts.hasOwnerMarker === 'function'
725
+ ? opts.hasOwnerMarker
726
+ : shared.hasWorktreeOwnerMarker;
573
727
  const _listManagedSpecs = typeof opts.listManagedSpecs === 'function'
574
728
  ? opts.listManagedSpecs
575
729
  : (() => {
@@ -680,59 +834,38 @@ function pruneOrphanWorktrees(opts) {
680
834
  }
681
835
  }
682
836
 
683
- try {
684
- // W-mq5o6bvy000x7191 (Layer 4): respect slow-cadence retry for paths
685
- // that have escalated past `worktreeStuckThreshold`.
686
- const wtResolved = path.resolve(wtPath);
687
- const slowRetryMs = (opts.config?.engine?.worktreeStuckSlowRetryMs)
688
- ?? shared.ENGINE_DEFAULTS.worktreeStuckSlowRetryMs
689
- ?? (30 * 60 * 1000);
690
- if (!_shouldSlowRetry(wtResolved, slowRetryMs)) {
691
- projStats.kept++; result.kept++;
692
- continue;
693
- }
694
- const suppressed = _isStuckPathSuppressed(wtResolved);
695
- const removed = _removeWorktree(wtPath, rootDir, wtParent);
696
- if (removed) {
697
- projStats.evicted++; result.evicted++;
698
- _markStuckSuccess(wtResolved, { writeToInbox: opts.writeToInbox });
699
- log('info', `worktree-gc: boot-evicted orphan ${name} for project ${project.name || 'default'}`);
700
- } else {
701
- projStats.failed++; result.failed++;
702
- const escResult = _maybeEscalateStuck(wtResolved, 'remove returned false', {
703
- config: opts.config, writeToInbox: opts.writeToInbox,
704
- reason: 'orphan-sweep',
705
- findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
706
- killImmediate: opts.killImmediate,
707
- });
708
- if (!escResult.alreadyEscalated && !suppressed) {
709
- log('warn', `worktree-gc: boot-evict returned false for ${wtPath}`);
710
- }
711
- // W-mq6f2fe0000557fa — if auto-reap killed any holders, retry the
712
- // removal once after a brief settle window. Clear the failure
713
- // cache entry so the post-reap retry isn't suppressed by the
714
- // 3-strike cooldown.
715
- if (escResult.reapedPids && escResult.reapedPids.length > 0) {
716
- _postReapRetry(wtPath, rootDir, wtParent, wtResolved, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
717
- }
718
- }
719
- } catch (rmErr) {
720
- projStats.failed++; result.failed++;
721
- const wtResolved = path.resolve(wtPath);
722
- const suppressed = _isStuckPathSuppressed(wtResolved);
723
- const escResult = _maybeEscalateStuck(wtResolved, rmErr && rmErr.message, {
724
- config: opts.config, writeToInbox: opts.writeToInbox,
725
- reason: 'orphan-sweep',
726
- findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
727
- killImmediate: opts.killImmediate,
728
- });
729
- if (!escResult.alreadyEscalated && !suppressed) {
730
- log('warn', `worktree-gc: boot-evict threw for ${wtPath}: ${rmErr.message}`);
731
- }
732
- if (escResult.reapedPids && escResult.reapedPids.length > 0) {
733
- _postReapRetry(wtPath, rootDir, wtParent, wtResolved, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
734
- }
735
- }
837
+ // P-c7e2b405 — route the slow-retry gate, ownership-marker gate (now
838
+ // requireOwnerMarker:true to match the out-of-root pruner), remove,
839
+ // escalate, and post-reap-retry through the shared helper. Counters are
840
+ // mutated in-place via `stats`.
841
+ const wtResolved = path.resolve(wtPath);
842
+ const slowRetryMs = (opts.config?.engine?.worktreeStuckSlowRetryMs)
843
+ ?? shared.ENGINE_DEFAULTS.worktreeStuckSlowRetryMs
844
+ ?? (30 * 60 * 1000);
845
+ reapAndRemoveWorktree({
846
+ worktreePath: wtPath,
847
+ gitRoot: rootDir,
848
+ parentDir: wtParent,
849
+ resolved: wtResolved,
850
+ slowRetryMs,
851
+ requireOwnerMarker: true,
852
+ escalateReason: 'orphan-sweep',
853
+ findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
854
+ killImmediate: opts.killImmediate,
855
+ config: opts.config,
856
+ writeToInbox: opts.writeToInbox,
857
+ sleepSyncFn: opts.sleepSyncFn,
858
+ stats: [projStats, result],
859
+ removeWorktree: _removeWorktree,
860
+ hasOwnerMarker: _hasOwnerMarker,
861
+ log,
862
+ enablePostReapRetry: true,
863
+ onRemovedLog: () => `worktree-gc: boot-evicted orphan ${name} for project ${project.name || 'default'}`,
864
+ onFailLog: (kind, errMsg) => kind === 'threw'
865
+ ? `worktree-gc: boot-evict threw for ${wtPath}: ${errMsg}`
866
+ : `worktree-gc: boot-evict returned false for ${wtPath}`,
867
+ onMarkerSkipLog: () => `worktree-gc: keeping in-root ${wtResolved} — no engine ownership marker (foreign/hand-made worktree)`,
868
+ });
736
869
  }
737
870
  result.perProject[project.name || rootDir] = projStats;
738
871
  }
@@ -756,6 +889,9 @@ function pruneOrphanWorktrees(opts) {
756
889
  * used to compute the inside-root set we DO NOT touch
757
890
  * here (the existing scanner handles it).
758
891
  * - log / fs / removeWorktree / listManagedSpecs / execSilent — injection
892
+ * - parsedTreesByProject — optional Map<resolvedLocalPath, parsedTrees>
893
+ * (P-d8f1a3c6). When set for a project, skip the self-shell of
894
+ * `git worktree list --porcelain` and reuse the supplied parsed trees.
759
895
  *
760
896
  * Protection rules mirror `pruneOrphanWorktrees`:
761
897
  * 1. The main checkout (path === projectLocalPath) is always kept.
@@ -792,6 +928,15 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
792
928
  const _parseWorktreePorcelain = typeof opts.parseWorktreePorcelain === 'function'
793
929
  ? opts.parseWorktreePorcelain
794
930
  : shared.parseWorktreePorcelain;
931
+ // P-d8f1a3c6 — optional pre-parsed worktree-trees map keyed on the resolved
932
+ // project localPath. When the periodic sweep already shelled + parsed
933
+ // `git worktree list --porcelain` for a project (and shares the result with
934
+ // the missing-dir reclaimer), reuse it and skip our own self-shell. Absent
935
+ // for a project ⇒ fall back to the self-shelling path so every direct caller
936
+ // and unit test keeps working unchanged.
937
+ const _parsedTreesByProject = opts.parsedTreesByProject instanceof Map
938
+ ? opts.parsedTreesByProject
939
+ : null;
795
940
  // W-mqecdoot — positive ownership gate. An out-of-root worktree is only
796
941
  // engine-managed if it carries the ownership marker we stamp at creation
797
942
  // (shared.writeWorktreeOwnerMarker). Worktrees a human created by hand
@@ -852,23 +997,26 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
852
997
  const wtParentPrefix = wtParentAbs + path.sep;
853
998
 
854
999
  const projStats = { scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0 };
855
- let raw = '';
856
- try {
857
- raw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
858
- cwd: rootDir, timeout: 15000, windowsHide: true,
859
- }) || '');
860
- } catch (e) {
861
- log('warn', `worktree-gc: git worktree list failed for ${project.name || rootDir}: ${e.message}`);
862
- result.perProject[project.name || rootDir] = projStats;
863
- continue;
864
- }
865
-
866
1000
  let trees;
867
- try { trees = _parseWorktreePorcelain(raw); }
868
- catch (e) {
869
- log('warn', `worktree-gc: parse worktree list failed for ${project.name || rootDir}: ${e.message}`);
870
- result.perProject[project.name || rootDir] = projStats;
871
- continue;
1001
+ if (_parsedTreesByProject && _parsedTreesByProject.has(rootDir)) {
1002
+ trees = _parsedTreesByProject.get(rootDir);
1003
+ } else {
1004
+ let raw = '';
1005
+ try {
1006
+ raw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
1007
+ cwd: rootDir, timeout: 15000, windowsHide: true,
1008
+ }) || '');
1009
+ } catch (e) {
1010
+ log('warn', `worktree-gc: git worktree list failed for ${project.name || rootDir}: ${e.message}`);
1011
+ result.perProject[project.name || rootDir] = projStats;
1012
+ continue;
1013
+ }
1014
+ try { trees = _parseWorktreePorcelain(raw); }
1015
+ catch (e) {
1016
+ log('warn', `worktree-gc: parse worktree list failed for ${project.name || rootDir}: ${e.message}`);
1017
+ result.perProject[project.name || rootDir] = projStats;
1018
+ continue;
1019
+ }
872
1020
  }
873
1021
 
874
1022
  for (const wt of trees) {
@@ -904,71 +1052,43 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
904
1052
  if (anchored) { projStats.kept++; result.kept++; continue; }
905
1053
  }
906
1054
 
907
- // W-mqecdoot — ownership gate. Only ever evict a worktree the engine
908
- // positively created (carries the ownership marker stamped at
909
- // `git worktree add` time). No marker → it's a human's hand-made worktree
910
- // (or a pre-marker legacy dir); KEEP it and do NOT escalate. The
1055
+ // P-c7e2b405 — ownership-marker gate, slow-retry gate, remove, escalate,
1056
+ // and post-reap-retry now all flow through the shared helper
1057
+ // (requireOwnerMarker:true — W-mqecdoot: only ever evict a worktree the
1058
+ // engine positively created; no marker → human's hand-made worktree, KEEP
1059
+ // and never escalate). parentDir is the dir *containing* this worktree
1060
+ // (not the project's worktreeRoot, since this dir lives out-of-root). The
911
1061
  // `git worktree prune --expire=now` below still drops registry entries
912
1062
  // whose dirs are already gone, which is safe regardless of ownership.
913
- let owned = false;
914
- try { owned = !!_hasOwnerMarker(wtAbs); }
915
- catch (_e) { owned = false; }
916
- if (!owned) {
917
- projStats.kept++; result.kept++;
918
- log('debug', `worktree-gc: keeping out-of-root ${wtAbs} — no engine ownership marker (foreign/hand-made worktree)`);
919
- continue;
920
- }
921
-
922
- // Slow-cadence gate for already-escalated stuck paths
923
1063
  const slowRetryMs = (opts.config?.engine?.worktreeStuckSlowRetryMs)
924
1064
  ?? shared.ENGINE_DEFAULTS.worktreeStuckSlowRetryMs
925
1065
  ?? (30 * 60 * 1000);
926
- if (!_shouldSlowRetry(wtAbs, slowRetryMs)) {
927
- projStats.kept++; result.kept++; continue;
928
- }
929
-
930
- // parentDir for the safety boundary check inside removeWorktree must be
931
- // the dir *containing* this worktree (not the project's worktreeRoot,
932
- // since that's not where this dir lives).
933
1066
  const parentDir = path.dirname(wtAbs);
934
- const suppressed = _isStuckPathSuppressed(wtAbs);
935
- try {
936
- const removed = _removeWorktree(wtAbs, rootDir, parentDir);
937
- if (removed) {
938
- projStats.evicted++; result.evicted++;
939
- _markStuckSuccess(wtAbs, { writeToInbox: opts.writeToInbox });
940
- try { shared.bumpWorktreeGcMetric('outOfRootEvicted'); } catch { /* optional */ }
941
- log('info', `worktree-gc: out-of-root evicted ${wtAbs} for project ${project.name || 'default'}`);
942
- } else {
943
- projStats.failed++; result.failed++;
944
- const escResult = _maybeEscalateStuck(wtAbs, 'remove returned false', {
945
- config: opts.config, writeToInbox: opts.writeToInbox,
946
- reason: 'orphan-sweep',
947
- findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
948
- killImmediate: opts.killImmediate,
949
- });
950
- if (!escResult.alreadyEscalated && !suppressed) {
951
- log('warn', `worktree-gc: out-of-root remove returned false for ${wtAbs}`);
952
- }
953
- if (escResult.reapedPids && escResult.reapedPids.length > 0) {
954
- _postReapRetry(wtAbs, rootDir, parentDir, wtAbs, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
955
- }
956
- }
957
- } catch (rmErr) {
958
- projStats.failed++; result.failed++;
959
- const escResult = _maybeEscalateStuck(wtAbs, rmErr && rmErr.message, {
960
- config: opts.config, writeToInbox: opts.writeToInbox,
961
- reason: 'orphan-sweep',
962
- findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
963
- killImmediate: opts.killImmediate,
964
- });
965
- if (!escResult.alreadyEscalated && !suppressed) {
966
- log('warn', `worktree-gc: out-of-root remove threw for ${wtAbs}: ${rmErr.message}`);
967
- }
968
- if (escResult.reapedPids && escResult.reapedPids.length > 0) {
969
- _postReapRetry(wtAbs, rootDir, parentDir, wtAbs, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
970
- }
971
- }
1067
+ reapAndRemoveWorktree({
1068
+ worktreePath: wtAbs,
1069
+ gitRoot: rootDir,
1070
+ parentDir,
1071
+ resolved: wtAbs,
1072
+ slowRetryMs,
1073
+ requireOwnerMarker: true,
1074
+ escalateReason: 'orphan-sweep',
1075
+ findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
1076
+ killImmediate: opts.killImmediate,
1077
+ config: opts.config,
1078
+ writeToInbox: opts.writeToInbox,
1079
+ sleepSyncFn: opts.sleepSyncFn,
1080
+ stats: [projStats, result],
1081
+ removeWorktree: _removeWorktree,
1082
+ hasOwnerMarker: _hasOwnerMarker,
1083
+ log,
1084
+ successMetric: 'outOfRootEvicted',
1085
+ enablePostReapRetry: true,
1086
+ onRemovedLog: () => `worktree-gc: out-of-root evicted ${wtAbs} for project ${project.name || 'default'}`,
1087
+ onFailLog: (kind, errMsg) => kind === 'threw'
1088
+ ? `worktree-gc: out-of-root remove threw for ${wtAbs}: ${errMsg}`
1089
+ : `worktree-gc: out-of-root remove returned false for ${wtAbs}`,
1090
+ onMarkerSkipLog: () => `worktree-gc: keeping out-of-root ${wtAbs} — no engine ownership marker (foreign/hand-made worktree)`,
1091
+ });
972
1092
  }
973
1093
 
974
1094
  // Always finish with `git worktree prune --expire=now` to drop registry
@@ -1019,7 +1139,9 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
1019
1139
  *
1020
1140
  * Injection points (all optional — default to the real implementations):
1021
1141
  * projects, log, fs, execSilent, parseWorktreePorcelain, isWorktreePathLive,
1022
- * db.
1142
+ * db, parsedTreesByProject (Map<resolvedLocalPath, parsedTrees>; P-d8f1a3c6 —
1143
+ * when set for a project, skip the self-shell of `git worktree list
1144
+ * --porcelain` and reuse the supplied parsed trees).
1023
1145
  *
1024
1146
  * Returns `{ scanned, reclaimed, skippedLive, failed, perProject }` where
1025
1147
  * `scanned` counts only the missing-dir candidates considered (dirs that still
@@ -1036,6 +1158,14 @@ function reclaimMissingDirWorktrees(opts) {
1036
1158
  const _parseWorktreePorcelain = typeof opts.parseWorktreePorcelain === 'function'
1037
1159
  ? opts.parseWorktreePorcelain
1038
1160
  : shared.parseWorktreePorcelain;
1161
+ // P-d8f1a3c6 — optional pre-parsed worktree-trees map keyed on the resolved
1162
+ // project localPath, shared with pruneOrphanWorktreesFromGitRegistry so the
1163
+ // periodic sweep only shells `git worktree list --porcelain` once per
1164
+ // project. Absent for a project ⇒ self-shell (back-compat for direct callers
1165
+ // and existing unit tests).
1166
+ const _parsedTreesByProject = opts.parsedTreesByProject instanceof Map
1167
+ ? opts.parsedTreesByProject
1168
+ : null;
1039
1169
  const _isWorktreePathLive = typeof opts.isWorktreePathLive === 'function'
1040
1170
  ? opts.isWorktreePathLive
1041
1171
  : shared.isWorktreePathLive;
@@ -1053,23 +1183,26 @@ function reclaimMissingDirWorktrees(opts) {
1053
1183
  if (!rootExists) continue;
1054
1184
 
1055
1185
  const projStats = { scanned: 0, reclaimed: 0, skippedLive: 0, failed: 0 };
1056
- let raw = '';
1057
- try {
1058
- raw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
1059
- cwd: rootDir, timeout: 15000, windowsHide: true,
1060
- }) || '');
1061
- } catch (e) {
1062
- log('warn', `worktree-gc: missing-dir reclaim list failed for ${project.name || rootDir}: ${e.message}`);
1063
- result.perProject[project.name || rootDir] = projStats;
1064
- continue;
1065
- }
1066
-
1067
1186
  let trees;
1068
- try { trees = _parseWorktreePorcelain(raw); }
1069
- catch (e) {
1070
- log('warn', `worktree-gc: missing-dir reclaim parse failed for ${project.name || rootDir}: ${e.message}`);
1071
- result.perProject[project.name || rootDir] = projStats;
1072
- continue;
1187
+ if (_parsedTreesByProject && _parsedTreesByProject.has(rootDir)) {
1188
+ trees = _parsedTreesByProject.get(rootDir);
1189
+ } else {
1190
+ let raw = '';
1191
+ try {
1192
+ raw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
1193
+ cwd: rootDir, timeout: 15000, windowsHide: true,
1194
+ }) || '');
1195
+ } catch (e) {
1196
+ log('warn', `worktree-gc: missing-dir reclaim list failed for ${project.name || rootDir}: ${e.message}`);
1197
+ result.perProject[project.name || rootDir] = projStats;
1198
+ continue;
1199
+ }
1200
+ try { trees = _parseWorktreePorcelain(raw); }
1201
+ catch (e) {
1202
+ log('warn', `worktree-gc: missing-dir reclaim parse failed for ${project.name || rootDir}: ${e.message}`);
1203
+ result.perProject[project.name || rootDir] = projStats;
1204
+ continue;
1205
+ }
1073
1206
  }
1074
1207
 
1075
1208
  let reclaimedHere = 0;
@@ -1137,6 +1270,7 @@ module.exports = {
1137
1270
  pruneOrphanWorktrees,
1138
1271
  pruneOrphanWorktreesFromGitRegistry,
1139
1272
  reclaimMissingDirWorktrees, // W-mqifblkf00149df5 — locked-initializing missing-dir reaper
1273
+ reapAndRemoveWorktree, // P-c7e2b405 — shared reap-then-remove core (exported for testing)
1140
1274
  // exported for testing (W-mq5o6bvy000x7191)
1141
1275
  _resetStuckPathsForTesting,
1142
1276
  _stuckPaths,