@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.
- package/dashboard/js/render-other.js +8 -0
- package/dashboard/js/settings.js +4 -1
- package/docs/completion-reports.md +17 -0
- package/docs/worktree-lifecycle.md +9 -5
- package/engine/cleanup.js +30 -1
- package/engine/queries.js +16 -5
- package/engine/shared.js +40 -13
- package/engine/worktree-gc.js +313 -179
- package/engine.js +55 -37
- package/package.json +1 -1
|
@@ -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) : '';
|
package/dashboard/js/settings.js
CHANGED
|
@@ -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
|
-
|
|
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`,
|
|
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 — `
|
|
96
|
+
### Layer 1a — quarantine rename via `shared._retryFsOp` with jittered backoff
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
2702
|
-
//
|
|
2703
|
-
//
|
|
2704
|
-
//
|
|
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
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
8391
|
-
|
|
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;
|
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,
|
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 =
|
|
1700
|
-
|
|
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
|
|
2638
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|