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