@yemi33/minions 0.1.2231 → 0.1.2233

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.
@@ -38,6 +38,14 @@ function _renderProjectBranch(p) {
38
38
  if (p.gitState === 'missing') return '<span class="project-warn" title="Project localPath does not exist on disk">(path not found)</span>';
39
39
  if (p.gitState === 'non-git') return '<span class="project-muted" title="Project path exists but is not a git repository">(not a git repo)</span>';
40
40
  if (p.gitState !== 'ok' || !p.gitBranch) return '';
41
+ // opg-microsoft/minions#295: the engine flags `gitStale: true` when tracked
42
+ // git refs have advanced past the cached snapshot (e.g. the operator ran
43
+ // `git switch` / `git pull` directly in a live checkout outside the
44
+ // dashboard). The cached gitBranch/gitDirty are then known-stale — rendering
45
+ // them would briefly show the OLD branch name as if current. Fall back to a
46
+ // neutral "refreshing" chip until the async cache refresh lands on the next
47
+ // poll, rather than presenting a known-stale branch/dirty indicator.
48
+ if (p.gitStale) return '<span class="project-muted" title="Branch changed outside the dashboard — refreshing…">(refreshing…)</span>';
41
49
  const branch = escHtml(p.gitBranch);
42
50
  const mainBranch = p.mainBranch ? escHtml(p.mainBranch) : '';
43
51
  const remoteDefault = p.remoteDefaultBranch ? escHtml(p.remoteDefaultBranch) : '';
@@ -244,7 +244,10 @@ async function openSettings() {
244
244
  } catch (e) { liveProj = null; }
245
245
  var remoteDefault = (liveProj && liveProj.remoteDefaultBranch) || '';
246
246
  var mismatch = !!(liveProj && liveProj.branchMismatch);
247
- var localBranch = (liveProj && liveProj.gitBranch) || '';
247
+ // Suppress the "Local HEAD" hint when the engine flags this status stale
248
+ // (opg-microsoft/minions#295) — the cached gitBranch is known-stale after
249
+ // an external checkout change and would show the OLD branch as current.
250
+ var localBranch = (liveProj && !liveProj.gitStale && liveProj.gitBranch) || '';
248
251
  var driftNote = mismatch
249
252
  ? '<div style="font-size:var(--text-sm);color:var(--yellow);margin-top:4px">⚠ Configured main (<code>' + escHtml(p.mainBranch || '') + '</code>) differs from origin/HEAD (<code>' + escHtml(remoteDefault) + '</code>) — config is likely stale.</div>'
250
253
  : '';
@@ -261,10 +261,27 @@ Defined in `engine/shared.js` as `FAILURE_CLASS`. Use the canonical hyphenated s
261
261
  | `out-of-context` | Context window exhausted | Flag for human review |
262
262
  | `max-turns` | Claude CLI `error_max_turns` — work in progress | Retry same agent |
263
263
  | `completion-nonce-mismatch` | Completion JSON missing or mismatched `nonce` (forged completion). See [Trust boundary](#trust-boundary). | Never retry (untrusted) |
264
+ | `worktree-preflight` | Pre-spawn worktree validation rejected the dispatch (nested-in-project, drive-root collapse, missing base). See [Pre-spawn preflight vs agent failure](#pre-spawn-preflight-vs-agent-failure). | Never retry |
265
+ | `live-checkout-dirty` | Live-checkout project tree had uncommitted changes; engine refused to spawn in-place. See [Pre-spawn preflight vs agent failure](#pre-spawn-preflight-vs-agent-failure). | Never retry |
266
+ | `workspace-manifest-repo-forbidden` | Dispatch routed an agent to a repo not in its `workspace_manifest.allowed_repos`. See [Pre-spawn preflight vs agent failure](#pre-spawn-preflight-vs-agent-failure). | Never retry |
264
267
  | `unknown` | Unclassified failure | Default retry logic |
265
268
 
266
269
  Use `"N/A"` when `status` is `success` or `partial` without a failure.
267
270
 
271
+ ## Pre-spawn preflight vs agent failure
272
+
273
+ A handful of `failure_class` values are written by the **engine at dispatch time**, *before the agent process is ever spawned*. They are emitted from `spawnAgent` (`engine.js`) while it sets up the worktree, validates the checkout, or enforces the workspace manifest — the engine calls `completeDispatch(... DISPATCH_RESULT.ERROR ...)` and returns without launching any runtime. **A completion report carrying one of these classes does not imply the agent ran any work**; no prompt was sent, no edits were attempted, and there is no agent-authored `note` or output to interpret.
274
+
275
+ | `failure_class` | Pre-spawn trigger | `engine/shared.js` |
276
+ |---|---|---|
277
+ | `worktree-preflight` | Worktree placement validation failed: the worktree would nest inside the project, the resolved root collapsed to a drive root, or the base directory was missing. | `FAILURE_CLASS.WORKTREE_PREFLIGHT` (~`shared.js:4279`) |
278
+ | `live-checkout-dirty` | Live-checkout mode (`project.checkoutMode: 'live'`) found uncommitted changes in `project.localPath`; the engine never `reset`/`clean`s the operator tree, so it refuses to spawn. | `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (~`shared.js:4289`) |
279
+ | `workspace-manifest-repo-forbidden` | The dispatch routed the agent to a repo not listed in its `workspace_manifest.allowed_repos`. Structural — widen the manifest or route the work elsewhere. | `FAILURE_CLASS.WORKSPACE_MANIFEST_REPO` (~`shared.js:4292`) |
280
+
281
+ `WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT` is **not** a standalone enum value: it is an `Error.code` thrown by the worktree path resolver (`shared.js`) which `spawnAgent` catches and maps to the non-retryable `worktree-preflight` class (`engine.js`). It surfaces in logs/diagnostics, but the report's `failure_class` is always `worktree-preflight`.
282
+
283
+ All three classes are **never retryable** — they signal a structural/environment problem the same agent would hit again. Resolve the underlying cause (clean the live tree, fix the worktree root, widen the manifest) before re-dispatching.
284
+
268
285
  ## No-op semantics
269
286
 
270
287
  A no-op completion declares that the agent correctly **declined** to do the work — the change was already shipped on master, the dispatch premise was wrong, the flagged review comment was an author-note, etc.
@@ -7,7 +7,7 @@ the **quarantine path** (dirty/divergent → quarantine dir + retry), and the
7
7
  Lifecycle keeps the cross-cutting invariants; the detail lives here.
8
8
 
9
9
  > Source of truth: `engine/worktree-pool.js`, `engine/shared.js#removeWorktree`
10
- > + `_retryFsOp`, `engine.js` (`_quarantineDirtyWorktree`, `_renameWithRetry`,
10
+ > + `_retryFsOp`, `engine.js` (`_quarantineDirtyWorktree`,
11
11
  > `_killGitDescendantsForWorktree`, `pruneOrphanWorktrees*`, `gcDispatchWorktreeIfOrphan`),
12
12
  > `engine/cleanup.js`. Last verified: 2026-06-09.
13
13
 
@@ -93,11 +93,15 @@ the `.git/index.lock` acquire around the untracked-cache refresh inside
93
93
  place. **Most incidents are closed by 1b alone.**
94
94
  Toggle: `ENGINE_DEFAULTS.statusProbeUseNoOptionalLocks` (default `true`).
95
95
 
96
- ### Layer 1a — `_renameWithRetry` with jittered backoff
96
+ ### Layer 1a — quarantine rename via `shared._retryFsOp` with jittered backoff
97
97
 
98
- 6 attempts × 250 ms base × 2^N exponential + 200 ms random jitter
99
- (~16 s worst-case). Only retries on `EBUSY|EPERM|EACCES|ENOTEMPTY`;
100
- rethrows other codes immediately. Toggles:
98
+ The quarantine rename (`fs.renameSync(worktreePath, quarantinedPath)`) is wrapped
99
+ in `shared._retryFsOp` (P-a1c4f7e2 collapsed the former bespoke
100
+ `engine.js#_renameWithRetry` into the shared helper). 6 attempts × 250 ms base ×
101
+ 2^N exponential + 200 ms random jitter (~16 s worst-case). Only retries on
102
+ `EBUSY|EPERM|EACCES|ENOTEMPTY` (`shared._WORKTREE_RETRYABLE_CODES`); rethrows
103
+ other codes immediately. `_quarantineDirtyWorktree` passes the rename-specific
104
+ budget explicitly. Toggles:
101
105
  `ENGINE_DEFAULTS.quarantineRenameRetryAttempts` (6),
102
106
  `quarantineRenameRetryBaseMs` (250).
103
107
 
package/engine/cleanup.js CHANGED
@@ -1591,6 +1591,34 @@ function runPeriodicWorktreeSweep(config) {
1591
1591
  let scanned = 0, kept = 0, evicted = 0, failed = 0, outOfRootEvicted = 0, prunedRegistry = 0;
1592
1592
  let missingDirReclaimed = 0, missingDirSkippedLive = 0;
1593
1593
  const _writeToInbox = (a, s, c) => { try { return shared.writeToInbox(a, s, c); } catch (_e) { return false; } };
1594
+
1595
+ // P-d8f1a3c6 — shell `git worktree list --porcelain` at most once per project
1596
+ // per tick and share the parsed trees with BOTH registry-based pruners
1597
+ // (out-of-root + missing-dir reclaim) instead of each self-shelling. Keyed on
1598
+ // the resolved localPath so projects sharing a parent repo only list once. A
1599
+ // project missing from the map (list/shell failure here) falls back to the
1600
+ // consumer's own self-shelling path. `pruneOrphanWorktrees` (r1, fs-readdir
1601
+ // based) never lists and is unaffected.
1602
+ const parsedTreesByProject = new Map();
1603
+ for (const project of projects) {
1604
+ if (!project || !project.localPath) continue;
1605
+ let rootDir;
1606
+ try { rootDir = path.resolve(String(project.localPath)); } catch { continue; }
1607
+ if (parsedTreesByProject.has(rootDir)) continue; // dedup shared-parent projects
1608
+ let rootExists = false;
1609
+ try { rootExists = fs.existsSync(rootDir); } catch { rootExists = false; }
1610
+ if (!rootExists) continue;
1611
+ try {
1612
+ const raw = String(shared.execSilent('git --no-optional-locks worktree list --porcelain', {
1613
+ cwd: rootDir, timeout: 15000, windowsHide: true,
1614
+ }) || '');
1615
+ parsedTreesByProject.set(rootDir, shared.parseWorktreePorcelain(raw));
1616
+ } catch (e) {
1617
+ // Leave unset → the consumer self-shells (existing fallback path).
1618
+ log('warn', `worktree-gc periodic list ${project.name || rootDir}: ${e.message}`);
1619
+ }
1620
+ }
1621
+
1594
1622
  try {
1595
1623
  const r1 = worktreeGc.pruneOrphanWorktrees({
1596
1624
  projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
@@ -1604,6 +1632,7 @@ function runPeriodicWorktreeSweep(config) {
1604
1632
  try {
1605
1633
  const r2 = worktreeGc.pruneOrphanWorktreesFromGitRegistry({
1606
1634
  projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
1635
+ parsedTreesByProject,
1607
1636
  });
1608
1637
  scanned += r2.scanned || 0;
1609
1638
  kept += r2.kept || 0;
@@ -1620,7 +1649,7 @@ function runPeriodicWorktreeSweep(config) {
1620
1649
  // interrupted `git worktree add` bricks the branch until a human intervenes.
1621
1650
  try {
1622
1651
  const r3 = worktreeGc.reclaimMissingDirWorktrees({
1623
- projects, log: _log, config,
1652
+ projects, log: _log, config, parsedTreesByProject,
1624
1653
  });
1625
1654
  missingDirReclaimed += r3.reclaimed || 0;
1626
1655
  missingDirSkippedLive += r3.skippedLive || 0;
package/engine/queries.js CHANGED
@@ -2698,12 +2698,23 @@ function getProjectGitStatus(localPath, configuredMainBranch = null) {
2698
2698
  // "behind: 8" after the user has already pulled to current — see
2699
2699
  // yemi33/minions#2848. Null those specific counters so the dashboard
2700
2700
  // shows a pending state for ahead/behind until the background probe
2701
- // settles; branch and dirty flags stay valid enough on the cached
2702
- // value (and update on the next poll once the probe lands). TTL-expiry
2703
- // fall-throughs (refsAdvanced=false) still return the prior counters
2704
- // because nothing local has actually changed there.
2701
+ // settles. TTL-expiry fall-throughs (refsAdvanced=false) still return the
2702
+ // prior counters because nothing local has actually changed there.
2703
+ //
2704
+ // opg-microsoft/minions#295: gitBranch/gitDirty on the cached value are
2705
+ // ALSO known-stale once refs have advanced (e.g. the operator ran
2706
+ // `git switch` / `git pull` directly in a live checkout outside the
2707
+ // dashboard). The cached value still carries the OLD branch name, so a
2708
+ // consumer rendering `gitBranch` as authoritative briefly shows the
2709
+ // pre-switch branch as if current. We keep the cached gitBranch/gitDirty
2710
+ // on the object (so the next poll can self-correct without a flash of
2711
+ // "missing"), but add a `gitStale: true` marker so render paths can avoid
2712
+ // presenting the known-stale branch/dirty as current and fall back to a
2713
+ // neutral "refreshing" presentation. The async refresh scheduled above
2714
+ // self-corrects on the next poll. We do NOT add a synchronous git probe
2715
+ // here — status rebuild must never block the event loop.
2705
2716
  if (refsAdvanced) {
2706
- return { ...cached.value, ahead: null, behind: null };
2717
+ return { ...cached.value, ahead: null, behind: null, gitStale: true };
2707
2718
  }
2708
2719
  return cached.value;
2709
2720
  }
package/engine/shared.js CHANGED
@@ -2032,7 +2032,7 @@ function writeToInbox(agentId, slug, content, _inboxDir, metadata) {
2032
2032
  // ── Process Spawning ────────────────────────────────────────────────────────
2033
2033
  // All child process calls go through these to ensure windowsHide: true
2034
2034
 
2035
- const { execSync: _execSync, spawnSync: _spawnSync, spawn: _spawn, exec: _cbExec, execFile: _cbExecFile } = require('child_process');
2035
+ const { execSync: _execSync, execFileSync: _execFileSync, spawnSync: _spawnSync, spawn: _spawn, exec: _cbExec, execFile: _cbExecFile } = require('child_process');
2036
2036
  const { promisify: _promisify } = require('util');
2037
2037
  const _execFileAsync = _promisify(_cbExecFile);
2038
2038
 
@@ -8296,6 +8296,13 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag) {
8296
8296
  } catch { /* best-effort — never throw from the skip-note writer */ }
8297
8297
  }
8298
8298
 
8299
+ // P-c7e2b405 — INTENTIONAL divergence from worktree-gc.reapAndRemoveWorktree:
8300
+ // this is the low-level `git worktree remove --force` → fs.rmSync → rd /s /q
8301
+ // removal primitive WITH its own EBUSY/_retryFsOp backoff loop. The engine-side
8302
+ // helper INJECTS this function as its `removeWorktree` and owns the
8303
+ // reap/escalate/marker-gate orchestration around it. It is deliberately NOT
8304
+ // folded into the helper (shared.js must not import the engine-side reaper —
8305
+ // layering boundary).
8299
8306
  function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8300
8307
  const resolved = path.resolve(wtPath);
8301
8308
  const resolvedRoot = path.resolve(worktreeRoot) + path.sep;
@@ -8326,14 +8333,24 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8326
8333
  return false;
8327
8334
  }
8328
8335
  } catch { /* bad gitRoot — fall through to the .git probe */ }
8329
- try {
8330
- const st = fs.lstatSync(path.join(resolved, '.git'));
8331
- if (st && st.isDirectory()) {
8332
- log('warn', `removeWorktree: refusing to remove ${wtPath} it is a real git repo (.git is a directory, not a linked-worktree pointer)`);
8333
- try { bumpWorktreeGcMetric('refusedRealRepo'); } catch { /* metric optional */ }
8334
- return false;
8335
- }
8336
- } catch { /* no .git, or unreadable — normal worktree husk; continue */ }
8336
+ // P-b3d9a162 — the `.git`-is-a-directory refusal, extracted so it can run at
8337
+ // BOTH the top of the function AND again immediately before the fallback
8338
+ // fs.rmSync. A Windows file-lock can release between the two checks — the
8339
+ // primary `git worktree remove --force` failing on that lock drops us into
8340
+ // the catch, where a freshly-exposed real `.git` directory would otherwise be
8341
+ // wiped. Returns true ⇒ the caller MUST abort (refuse the delete).
8342
+ const _refuseIfRealRepo = (resolved) => {
8343
+ try {
8344
+ const st = fs.lstatSync(path.join(resolved, '.git'));
8345
+ if (st && st.isDirectory()) {
8346
+ log('warn', `removeWorktree: refusing to remove ${wtPath} — it is a real git repo (.git is a directory, not a linked-worktree pointer)`);
8347
+ try { bumpWorktreeGcMetric('refusedRealRepo'); } catch { /* metric optional */ }
8348
+ return true;
8349
+ }
8350
+ } catch { /* no .git, or unreadable — normal worktree husk; continue */ }
8351
+ return false;
8352
+ };
8353
+ if (_refuseIfRealRepo(resolved)) return false;
8337
8354
  // W-mq5rwwss000f30a7 — never wipe a worktree while an agent is actively
8338
8355
  // dispatched inside it. isWorktreePathLive fails OPEN (returns true) when
8339
8356
  // the dispatches table is unreachable, so we err on the side of leaking
@@ -8363,12 +8380,19 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8363
8380
 
8364
8381
  bumpWorktreeGcMetric('attempts');
8365
8382
  try {
8366
- exec(`git worktree remove "${wtPath}" --force`, { cwd: gitRoot, stdio: 'pipe', timeout: 15000, windowsHide: true });
8383
+ // P-1d7a4f80 argv form (shell:false) so wtPath can never be re-parsed by a shell.
8384
+ shellSafeGitSync(['worktree', 'remove', wtPath, '--force'], { cwd: gitRoot, timeout: 15000 });
8367
8385
  _removeWorktreeFailures.delete(resolved);
8368
8386
  bumpWorktreeGcMetric('success');
8369
8387
  return true;
8370
8388
  } catch (gitErr) {
8371
8389
  try {
8390
+ // P-b3d9a162 — re-run the real-repo refusal: a lock that released only
8391
+ // AFTER the top-of-function probe (and made the primary `git worktree
8392
+ // remove` throw above) can expose a real `.git` directory right here.
8393
+ // Refuse before the fallback rmSync so we never recurse-delete a real
8394
+ // repo on the fallback path.
8395
+ if (_refuseIfRealRepo(resolved)) return false;
8372
8396
  // W-mq5o6bvy000x7191 (Layer 1): retry fs.rmSync with exponential backoff
8373
8397
  // for transient Windows file-locks (EPERM/EBUSY/EACCES/ENOTEMPTY) before
8374
8398
  // falling through to rd /s /q. A single AV/Explorer/vscode lock during
@@ -8377,7 +8401,7 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8377
8401
  () => fs.rmSync(resolved, { recursive: true, force: true }),
8378
8402
  `fs.rmSync(${resolved})`
8379
8403
  );
8380
- try { exec('git worktree prune', { cwd: gitRoot, stdio: 'pipe', timeout: 10000, windowsHide: true }); } catch {}
8404
+ try { shellSafeGitSync(['worktree', 'prune'], { cwd: gitRoot, timeout: 10000 }); } catch {}
8381
8405
  _removeWorktreeFailures.delete(resolved);
8382
8406
  bumpWorktreeGcMetric('success');
8383
8407
  if (attempt > 1) bumpWorktreeGcMetric('successAfterRetry');
@@ -8387,8 +8411,11 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
8387
8411
  // locked files, and partially-deleted directories (not just EPERM)
8388
8412
  if (process.platform === 'win32') {
8389
8413
  try {
8390
- exec(`cmd /c rd /s /q "${resolved}"`, { stdio: 'pipe', timeout: 15000, windowsHide: true });
8391
- try { exec('git worktree prune', { cwd: gitRoot, stdio: 'pipe', timeout: 10000, windowsHide: true }); } catch {}
8414
+ // P-1d7a4f80 argv form (shell:false). `rd` is a cmd.exe builtin so it
8415
+ // must run via `cmd /c`, but the worktree path is passed as a discrete
8416
+ // argv element rather than interpolated into a shell string.
8417
+ _execFileSync('cmd', ['/c', 'rd', '/s', '/q', resolved], { stdio: 'pipe', timeout: 15000, windowsHide: true });
8418
+ try { shellSafeGitSync(['worktree', 'prune'], { cwd: gitRoot, timeout: 10000 }); } catch {}
8392
8419
  _removeWorktreeFailures.delete(resolved);
8393
8420
  bumpWorktreeGcMetric('success');
8394
8421
  return true;
@@ -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,
package/engine.js CHANGED
@@ -1047,37 +1047,6 @@ function _statusPorcelainCmd() {
1047
1047
  : 'git status --porcelain';
1048
1048
  }
1049
1049
 
1050
- // W-mq5n1zx5 — Layer 1a: rename worktree dir with jittered backoff. The
1051
- // raw `fs.renameSync` used to throw Windows EBUSY/EPERM/EACCES if a
1052
- // lingering `git.exe` descendant (typically leaked by a status-probe
1053
- // timeout) still held a packfile handle. We retry with capped exponential
1054
- // backoff + random jitter so the descendant has time to exit on its own.
1055
- // Worst-case wall time ≈ baseMs * (2^attempts) ≈ 16s when attempts=6,
1056
- // baseMs=250 — small enough not to wedge a tick, large enough to clear
1057
- // the typical race. Throws the LAST error on exhaustion so the caller
1058
- // can decide whether to fall back to `git worktree remove --force`.
1059
- async function _renameWithRetry(src, dst, opts = {}) {
1060
- const attempts = Number(opts.attempts) > 0
1061
- ? Number(opts.attempts)
1062
- : ENGINE_DEFAULTS.quarantineRenameRetryAttempts;
1063
- const baseMs = Number(opts.baseMs) > 0
1064
- ? Number(opts.baseMs)
1065
- : ENGINE_DEFAULTS.quarantineRenameRetryBaseMs;
1066
- let lastErr;
1067
- for (let i = 0; i < attempts; i++) {
1068
- try { fs.renameSync(src, dst); return { attempts: i + 1 }; }
1069
- catch (e) {
1070
- if (!['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(e.code)) throw e;
1071
- lastErr = e;
1072
- if (i < attempts - 1) {
1073
- const delay = baseMs * (2 ** i) + Math.random() * 200;
1074
- await new Promise(r => setTimeout(r, delay));
1075
- }
1076
- }
1077
- }
1078
- throw lastErr;
1079
- }
1080
-
1081
1050
  // W-mqila0t5 — resolve the worktree-holder reap probe timeout, honoring a
1082
1051
  // `config.engine.statusProbeKillTimeoutMs` override (the dashboard Settings
1083
1052
  // control persists there) and falling back to the ENGINE_DEFAULTS default.
@@ -1223,7 +1192,17 @@ function _reapWorktreeHolders(worktreePath, opts = {}) {
1223
1192
  }
1224
1193
  if (live) { result.skipped = true; result.reason = 'live'; return result; }
1225
1194
 
1226
- // Layer 0 — legacy git.exe-by-cmdline sweep.
1195
+ // Layer 0 — legacy git.exe-by-cmdline sweep. NOT redundant with Layer 2
1196
+ // (P-e2a6c9d4, evaluated): Layer 2 (findProcessCwdHolders) matches purely on a
1197
+ // process's CWD resolving at/under THIS worktree (engine/shared.js
1198
+ // _windowsCwdProbeScript + _parseCwdHolderLines filter on cwd only). Layer 0
1199
+ // matches `git.exe` by `CommandLine -like '*<worktreePath>*'` regardless of CWD.
1200
+ // The case Layer 2 cannot see: a git.exe the engine itself spawns FROM the repo
1201
+ // root referencing the worktree by argument — e.g. `git -C <gitRoot> worktree
1202
+ // remove <wt>` / `git worktree prune` / `git worktree add <wt>` — its CWD is the
1203
+ // repo root, not under <wt>, so Layer 2 never emits it, but it can still hold a
1204
+ // packfile/dir handle on <wt>. Layer 0's command-line match is the only layer
1205
+ // that reaps it. Keep both.
1227
1206
  try { result.layer0 = (layer0Fn(worktreePath).killed) || 0; }
1228
1207
  catch { /* best-effort */ }
1229
1208
 
@@ -1653,6 +1632,11 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
1653
1632
  // field is populated and `result.quarantined` stays false (env-blocked
1654
1633
  // path); a normal return means quarantine succeeded.
1655
1634
  async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOpts, diag = {}) {
1635
+ // P-c7e2b405 — INTENTIONAL divergence from worktree-gc.reapAndRemoveWorktree:
1636
+ // this is the bespoke dirty-worktree quarantine flow (rename → branch backup-ref
1637
+ // → reset → fetch), NOT a plain reap-then-remove. It is deliberately NOT folded
1638
+ // into the shared helper. It does share the same live-guard + holder-reap
1639
+ // primitives (isWorktreePathLive + _reapWorktreeHolders below).
1656
1640
  const ts = Date.now();
1657
1641
  const quarantinedPath = `${worktreePath}-quarantine-${ts}`;
1658
1642
  _bumpQuarantineOutcome('attempts', 1);
@@ -1693,11 +1677,24 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
1693
1677
  // W-mq5n1zx5 Layer 1a: rename with jittered backoff. Replaces the bare
1694
1678
  // `fs.renameSync(worktreePath, quarantinedPath)` that used to throw
1695
1679
  // Windows EBUSY uncatchably and burn the WI's auto-recovery budget.
1680
+ // Consolidated onto shared._retryFsOp (P-a1c4f7e2): same base*2^i+jitter
1681
+ // backoff math and identical retryable-code set (shared._WORKTREE_RETRYABLE_CODES
1682
+ // === {EBUSY,EPERM,EACCES,ENOTEMPTY}, the exact set the old _renameWithRetry
1683
+ // used). Pass the quarantine-specific attempts/baseMs so the rename keeps its
1684
+ // own retry budget rather than adopting the worktree-remove default. _retryFsOp
1685
+ // is synchronous (blocking sleepMs backoff), so no await here.
1696
1686
  let renameAttempts = 0;
1697
1687
  let renameError = null;
1698
1688
  try {
1699
- const r = await _renameWithRetry(worktreePath, quarantinedPath);
1700
- renameAttempts = r.attempts || 1;
1689
+ const r = shared._retryFsOp(
1690
+ () => fs.renameSync(worktreePath, quarantinedPath),
1691
+ `rename ${worktreePath} -> ${quarantinedPath}`,
1692
+ {
1693
+ attempts: ENGINE_DEFAULTS.quarantineRenameRetryAttempts,
1694
+ baseMs: ENGINE_DEFAULTS.quarantineRenameRetryBaseMs,
1695
+ },
1696
+ );
1697
+ renameAttempts = r.attempt || 1;
1701
1698
  } catch (e) {
1702
1699
  renameError = e;
1703
1700
  }
@@ -2634,8 +2631,29 @@ async function spawnAgent(dispatchItem, config) {
2634
2631
  log('info', `Retrying -b create after prune for ${branchName}`);
2635
2632
  try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
2636
2633
  removeStaleIndexLock(rootDir);
2637
- // Clean up partial worktree directory from failed attempt
2638
- try { if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* optional */ }
2634
+ // Clean up partial worktree directory from the failed -b
2635
+ // attempt. Keep this a RAW fs.rmSync: this husk pre-dates
2636
+ // `git worktree add`, so git does not yet own it and
2637
+ // shared.removeWorktree's `git worktree remove --force` would
2638
+ // no-op on it (P-b3d9a162). Two guards before the delete:
2639
+ // 1. isWorktreePathLive (fail-open): skip if another
2640
+ // dispatch raced onto this path — it returns true on a
2641
+ // SQL outage, so we leak the husk rather than nuke a
2642
+ // sibling agent's live worktree. Exclude our OWN id.
2643
+ // 2. belt-and-braces: never recurse-delete a path that
2644
+ // carries its own `.git` DIRECTORY (a mis-pointed
2645
+ // worktreePath onto a real repo) — a linked worktree's
2646
+ // `.git` is a FILE, so this never blocks a real husk.
2647
+ try {
2648
+ const _huskGit = path.join(worktreePath, '.git');
2649
+ let _huskIsRealRepo = false;
2650
+ try { _huskIsRealRepo = fs.existsSync(_huskGit) && fs.lstatSync(_huskGit).isDirectory(); } catch { /* unreadable — treat as husk */ }
2651
+ if (fs.existsSync(worktreePath)
2652
+ && !_huskIsRealRepo
2653
+ && !shared.isWorktreePathLive(worktreePath, { excludeDispatchId: id })) {
2654
+ fs.rmSync(worktreePath, { recursive: true, force: true });
2655
+ }
2656
+ } catch { /* optional */ }
2639
2657
  try {
2640
2658
  await runWorktreeAdd(rootDir, worktreePath, ['-b', branchName, _freshCreateBase], _worktreeGitOpts, 0);
2641
2659
  } catch (e1b) {
@@ -10016,7 +10034,7 @@ module.exports = {
10016
10034
  gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
10017
10035
  buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
10018
10036
  isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
10019
- _renameWithRetry, _statusPorcelainCmd, _killGitDescendantsForWorktree, _bumpQuarantineOutcome, // exported for testing (W-mq5n1zx5)
10037
+ _statusPorcelainCmd, _killGitDescendantsForWorktree, _bumpQuarantineOutcome, // exported for testing (W-mq5n1zx5)
10020
10038
  _reapWorktreeHolders, _findTerminalWorktreeOwners, // exported for testing (W-mqila0t5 — CWD-pinned holder reap)
10021
10039
  pruneStaleWorktreeForBranch, // exported for testing
10022
10040
  findExistingWorktree, // exported for testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2231",
3
+ "version": "0.1.2233",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"