@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.
- package/bin/minions.js +29 -2
- package/dashboard/js/render-other.js +37 -4
- package/dashboard/js/render-work-items.js +8 -22
- package/dashboard/js/settings.js +2 -0
- package/dashboard.js +6 -0
- package/docs/completion-reports.md +17 -0
- package/docs/worktree-lifecycle.md +9 -5
- package/engine/cleanup.js +30 -1
- package/engine/restart-health.js +54 -2
- package/engine/shared.js +51 -13
- package/engine/worktree-gc.js +313 -179
- package/engine.js +55 -37
- package/package.json +1 -1
package/engine/worktree-gc.js
CHANGED
|
@@ -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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
//
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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,
|