@yemi33/minions 0.1.1949 → 0.1.1951

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.
Files changed (40) hide show
  1. package/dashboard/js/command-center.js +9 -0
  2. package/dashboard/js/modal-qa.js +10 -0
  3. package/dashboard/js/refresh.js +4 -0
  4. package/dashboard/js/render-dispatch.js +25 -0
  5. package/dashboard/js/render-other.js +109 -2
  6. package/dashboard/js/settings.js +1 -1
  7. package/dashboard/layout.html +2 -2
  8. package/dashboard/pages/engine.html +6 -0
  9. package/dashboard/slim.html +1987 -0
  10. package/dashboard/styles.css +8 -0
  11. package/dashboard.js +450 -40
  12. package/docs/completion-reports.md +25 -0
  13. package/docs/design-state-storage.md +1 -1
  14. package/docs/slim-ux/architecture-suggestions.md +467 -0
  15. package/docs/slim-ux/concepts.md +824 -0
  16. package/engine/ado-mcp-wrapper.js +33 -7
  17. package/engine/ado.js +123 -15
  18. package/engine/cc-worker-pool.js +41 -0
  19. package/engine/cleanup.js +71 -34
  20. package/engine/cli.js +37 -0
  21. package/engine/dispatch.js +32 -9
  22. package/engine/features.js +6 -0
  23. package/engine/gh-token.js +137 -0
  24. package/engine/github.js +166 -29
  25. package/engine/issues.js +29 -0
  26. package/engine/keep-process-sweep.js +397 -0
  27. package/engine/lifecycle.js +150 -33
  28. package/engine/playbook.js +17 -0
  29. package/engine/queries.js +71 -0
  30. package/engine/recovery.js +6 -0
  31. package/engine/shared.js +481 -30
  32. package/engine/spawn-agent.js +44 -2
  33. package/engine/timeout.js +34 -11
  34. package/engine/worktree-pool.js +410 -0
  35. package/engine.js +643 -119
  36. package/package.json +6 -3
  37. package/playbooks/review.md +2 -0
  38. package/playbooks/shared-rules.md +3 -1
  39. package/prompts/cc-system.md +24 -0
  40. package/engine/copilot-models.json +0 -5
@@ -38,6 +38,7 @@ const path = require('path');
38
38
  const { runFile, cleanChildEnv, killGracefully, killImmediate, killByPidsImmediate, listProcessDescendants, ts, resolveEngineCacheDir } = require('./shared');
39
39
  const { resolveRuntime } = require('./runtimes');
40
40
  const { acquireAdoTokenSync, isLikelyAdoToken } = require('./ado-token');
41
+ const keepProcessSweep = require('./keep-process-sweep');
41
42
 
42
43
  // ─── Pure helpers (exported for tests) ──────────────────────────────────────
43
44
 
@@ -534,8 +535,49 @@ function main() {
534
535
  if (trackedDescendants.size || gotFirstOutput) {
535
536
  snapshotDescendants();
536
537
  if (trackedDescendants.size) {
537
- const reaped = killByPidsImmediate([...trackedDescendants]);
538
- try { fs.appendFileSync(debugPath, `DESCENDANTS reaped=${reaped}/${trackedDescendants.size}\n`); } catch {}
538
+ // W-mp68q6ke0010de68 opt-in keep_processes flag: agents whose work
539
+ // item carried `meta.keep_processes: true` may have written
540
+ // `agents/<id>/keep-pids.json` declaring specific descendant PIDs the
541
+ // engine MUST NOT reap. Resolve agentId from MINIONS_LIVE_OUTPUT_PATH
542
+ // (engine.js:1451 sets it to agents/<agentId>/live-output.log) and
543
+ // subtract validated, alive PIDs from the kill set. Missing or
544
+ // invalid file → fall through to today's behavior (reap everything).
545
+ let toKillPids = [...trackedDescendants];
546
+ let kept = [];
547
+ let keepRecord = null;
548
+ let keepReason = null;
549
+ try {
550
+ const liveOut = process.env.MINIONS_LIVE_OUTPUT_PATH;
551
+ const agentId = liveOut ? path.basename(path.dirname(liveOut)) : '';
552
+ if (agentId) {
553
+ // W-mp6k7ywi000fa33c — per-WI override (set by engine when
554
+ // meta.keep_processes_skip_workdir_check is true) bypasses the
555
+ // requireGitWorkdir check so legitimate non-git keep_processes
556
+ // use cases (e.g., a daemon under /tmp) still anchor.
557
+ const reapOpts = process.env.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK === '1'
558
+ ? { requireGitWorkdir: false }
559
+ : {};
560
+ const plan = keepProcessSweep.computeReapPlan(toKillPids, agentId, reapOpts);
561
+ toKillPids = plan.toKill;
562
+ kept = plan.kept;
563
+ keepRecord = plan.record;
564
+ keepReason = plan.reason;
565
+ }
566
+ } catch (e) {
567
+ try { fs.appendFileSync(debugPath, `KEEP-PIDS error: ${e.message}\n`); } catch {}
568
+ }
569
+ if (kept.length) {
570
+ try {
571
+ fs.appendFileSync(
572
+ debugPath,
573
+ `KEPT pids=[${kept.join(',')}] purpose="${(keepRecord?.purpose || '').slice(0, 200)}" wi=${keepRecord?.wi_id || ''}\n`,
574
+ );
575
+ } catch {}
576
+ } else if (keepReason) {
577
+ try { fs.appendFileSync(debugPath, `KEEP-PIDS skipped: ${keepReason}\n`); } catch {}
578
+ }
579
+ const reaped = toKillPids.length ? killByPidsImmediate(toKillPids) : 0;
580
+ try { fs.appendFileSync(debugPath, `DESCENDANTS reaped=${reaped}/${toKillPids.length} kept=${kept.length}\n`); } catch {}
539
581
  }
540
582
  }
541
583
  // Prefer the 'exit' event's code/signal when present — see note above.
package/engine/timeout.js CHANGED
@@ -181,19 +181,27 @@ function isTrackedProcessAlive(procInfo) {
181
181
  }
182
182
  }
183
183
 
184
+ // Read the on-disk PID for a dispatch ID, or null if missing/invalid. Shared by
185
+ // `isOsPidAliveForDispatch` and the recycled-PID command-line cross-check below
186
+ // so both paths agree on which integer PID to interrogate.
187
+ function _readDispatchPid(itemId) {
188
+ const safeId = String(itemId || '').replace(/[:\\/*?"<>|]/g, '-');
189
+ const pidPath = path.join(ENGINE_DIR, 'tmp', `pid-${safeId}.pid`);
190
+ let raw;
191
+ try { raw = fs.readFileSync(pidPath, 'utf8'); }
192
+ catch { return null; }
193
+ const pid = parseInt(String(raw).trim(), 10);
194
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
195
+ }
196
+
184
197
  // Last-resort liveness check via the on-disk PID file (engine/tmp/pid-<safeId>.pid).
185
198
  // Used by orphan detection to avoid false-positive kills when the engine has lost the
186
199
  // tracked process handle (engine restart, never-tracked spawn, etc.) but the OS-level
187
200
  // child process is still alive and healthy. The safeId here mirrors engine.js spawn
188
201
  // (id.replace(/[:\\/*?"<>|]/g, '-')) — same pattern engine/cli.js uses to re-attach.
189
202
  function isOsPidAliveForDispatch(itemId) {
190
- const safeId = String(itemId || '').replace(/[:\\/*?"<>|]/g, '-');
191
- const pidPath = path.join(ENGINE_DIR, 'tmp', `pid-${safeId}.pid`);
192
- let raw;
193
- try { raw = fs.readFileSync(pidPath, 'utf8'); }
194
- catch { return false; }
195
- const pid = parseInt(String(raw).trim(), 10);
196
- if (!Number.isFinite(pid) || pid <= 0) return false;
203
+ const pid = _readDispatchPid(itemId);
204
+ if (!pid) return false;
197
205
  try { process.kill(pid, 0); return true; }
198
206
  catch { return false; }
199
207
  }
@@ -324,7 +332,12 @@ function checkTimeouts(config) {
324
332
  // detectPhantom downstream lets enforcePrAttachmentContract route phantom
325
333
  // hard-fails through the _phantomRetryCount budget instead of bypassing
326
334
  // the retry counter entirely (P-d9a3e6f4).
327
- runPostCompletionHooks(item, item.agent, processExitCode, fullLogForHooks, config, { detectPhantom: true }).catch(e => log('warn', 'post-completion hooks: ' + e.message));
335
+ // P-d2a8f6c1: hand the per-spawn nonce to lifecycle for trust-boundary
336
+ // validation. Read it from activeProcesses before the hasProcess branch
337
+ // below deletes the entry.
338
+ const expectedNonce = activeProcesses.get(item.id)?._completionNonce || null;
339
+ const completionNonceRequired = config?.engine?.completionNonceRequired ?? ENGINE_DEFAULTS.completionNonceRequired;
340
+ runPostCompletionHooks(item, item.agent, processExitCode, fullLogForHooks, config, { detectPhantom: true, expectedNonce, completionNonceRequired }).catch(e => log('warn', 'post-completion hooks: ' + e.message));
328
341
 
329
342
  if (hasProcess) {
330
343
  shared.killImmediate(activeProcesses.get(item.id)?.proc);
@@ -341,7 +354,7 @@ function checkTimeouts(config) {
341
354
 
342
355
  const procInfo = activeProcesses.get(item.id);
343
356
  const hasProcess = !!procInfo;
344
- const processAlive = isTrackedProcessAlive(procInfo);
357
+ let processAlive = isTrackedProcessAlive(procInfo);
345
358
  const liveLogPath = path.join(AGENTS_DIR, item.agent, 'live-output.log');
346
359
  let lastActivity = item.started_at ? new Date(item.started_at).getTime() : 0;
347
360
 
@@ -475,8 +488,18 @@ function checkTimeouts(config) {
475
488
  if (!processAlive && (canReapDeadProcess || silentMs > staleOrphanTimeout) && (Date.now() > engineRestartGraceUntil || canReapDeadProcess)) {
476
489
  // Last-resort PID check: lost tracked handle but OS process may still be alive.
477
490
  if (isOsPidAliveForDispatch(item.id)) {
478
- log('info', `Orphan check: ${item.agent} (${item.id}) silent ${silentSec}s but OS PID is alive — keeping [${_logState}]`);
479
- continue;
491
+ // Cross-check the PID's command line: a recycled OS PID can be alive
492
+ // under an unrelated process (Windows reuses PIDs aggressively). Without
493
+ // this cmdline gate, the dispatch is parked indefinitely as 'active'
494
+ // pinned to a process that has nothing to do with the agent.
495
+ const livePid = _readDispatchPid(item.id);
496
+ if (livePid && !shared.isProcessCommandLineMatchingAgent(livePid)) {
497
+ log('warn', `Orphan check: ${item.agent} (${item.id}) — PID ${livePid} alive but command line doesn't match agent, treating as dead`);
498
+ processAlive = false;
499
+ } else {
500
+ log('info', `Orphan check: ${item.agent} (${item.id}) silent ${silentSec}s but OS PID is alive — keeping [${_logState}]`);
501
+ continue;
502
+ }
480
503
  }
481
504
  // Final safety scan: the normal 64KB tail scan can miss a clean exit if
482
505
  // later runtime payloads or diagnostics push the sentinel outside the tail.
@@ -0,0 +1,410 @@
1
+ /**
2
+ * engine/worktree-pool.js — per-project warm pool of git worktrees that can be
3
+ * recycled across branches (W-mp73ya3e000me6c5).
4
+ *
5
+ * Default-off: `ENGINE_DEFAULTS.worktreePoolSize = 0`. Per-project opt-in via
6
+ * `projects[].worktreePoolSize`. When enabled, completed worktrees are parked
7
+ * detached at `origin/<mainBranch>` and reused by the next dispatch for the
8
+ * same project — saving the cold install/build cost on subsequent WIs.
9
+ *
10
+ * Concurrency: all state flips go through `mutateJsonFileLocked` so two
11
+ * spawning ticks cannot double-borrow the same idle entry. Per CLAUDE.md, git
12
+ * commands NEVER run inside the lock callback — callers must do `tryBorrow`,
13
+ * then run git in their own scope, then call `evictEntry` on failure.
14
+ *
15
+ * Idle worktrees are detached at origin/<main>, NOT checked out on the local
16
+ * `main` branch. Local-main is typically already checked out in the project
17
+ * root, and git refuses two checkouts of the same branch. Detached HEAD
18
+ * sidesteps that entirely while keeping the working tree warm with main's
19
+ * package.json + lockfile + node_modules.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const shared = require('./shared');
25
+
26
+ const { log, ENGINE_DEFAULTS, mutateJsonFileLocked, safeJson } = shared;
27
+
28
+ const WORKTREE_POOL_FILENAME = 'worktree-pool.json';
29
+ const POOL_STATE = Object.freeze({ IDLE: 'idle', BORROWED: 'borrowed' });
30
+
31
+ function getPoolPath() {
32
+ return path.join(shared.MINIONS_DIR, 'engine', WORKTREE_POOL_FILENAME);
33
+ }
34
+
35
+ function _normalizePath(p) {
36
+ if (!p) return '';
37
+ let resolved;
38
+ try { resolved = path.resolve(String(p)); }
39
+ catch { return ''; }
40
+ // Strip trailing separators
41
+ resolved = resolved.replace(/[\\/]+$/, '');
42
+ // Case-insensitive on Windows
43
+ if (process.platform === 'win32') resolved = resolved.toLowerCase();
44
+ // Use forward-slashes for stable comparison across OS path APIs
45
+ return resolved.replace(/\\/g, '/');
46
+ }
47
+
48
+ function _nowIso(now) {
49
+ return new Date(Number.isFinite(now) ? now : Date.now()).toISOString();
50
+ }
51
+
52
+ function _entriesArray(data) {
53
+ if (!data || typeof data !== 'object' || !Array.isArray(data.entries)) return [];
54
+ return data.entries;
55
+ }
56
+
57
+ function readPool() {
58
+ const data = safeJson(getPoolPath());
59
+ if (!data || !Array.isArray(data.entries)) return { entries: [] };
60
+ return data;
61
+ }
62
+
63
+ function mutateWorktreePool(mutator) {
64
+ return mutateJsonFileLocked(getPoolPath(), (data) => {
65
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = { entries: [] };
66
+ if (!Array.isArray(data.entries)) data.entries = [];
67
+ const next = mutator(data);
68
+ return next === undefined ? data : next;
69
+ }, { defaultValue: { entries: [] }, skipWriteIfUnchanged: true });
70
+ }
71
+
72
+ /**
73
+ * Resolve effective pool size for a project. Per-project override beats the
74
+ * fleet-wide default. Returns 0 (disabled) when nothing is configured.
75
+ */
76
+ function getProjectPoolSize(projectName, config) {
77
+ config = config || {};
78
+ if (projectName && Array.isArray(config.projects)) {
79
+ const proj = config.projects.find(p => p && p.name === projectName);
80
+ if (proj && Number.isFinite(Number(proj.worktreePoolSize))) {
81
+ return Math.max(0, Math.floor(Number(proj.worktreePoolSize)));
82
+ }
83
+ }
84
+ const fleet = config.engine?.worktreePoolSize ?? ENGINE_DEFAULTS.worktreePoolSize;
85
+ if (Number.isFinite(Number(fleet))) return Math.max(0, Math.floor(Number(fleet)));
86
+ return 0;
87
+ }
88
+
89
+ function getPoolIdleTtlMs(config) {
90
+ config = config || {};
91
+ const v = config.engine?.worktreePoolIdleTtlMs ?? ENGINE_DEFAULTS.worktreePoolIdleTtlMs;
92
+ if (Number.isFinite(Number(v))) return Math.max(60000, Number(v));
93
+ return 6 * 3600 * 1000;
94
+ }
95
+
96
+ function _findEntryByPath(entries, wtPath) {
97
+ const norm = _normalizePath(wtPath);
98
+ if (!norm) return -1;
99
+ return entries.findIndex(e => e && _normalizePath(e.path) === norm);
100
+ }
101
+
102
+ function isPoolMember(wtPath) {
103
+ const entries = _entriesArray(readPool());
104
+ return _findEntryByPath(entries, wtPath) !== -1;
105
+ }
106
+
107
+ function getEntry(wtPath) {
108
+ const entries = _entriesArray(readPool());
109
+ const idx = _findEntryByPath(entries, wtPath);
110
+ return idx === -1 ? null : entries[idx];
111
+ }
112
+
113
+ /**
114
+ * Atomically borrow an idle entry for a project. Returns the borrowed entry
115
+ * (with state flipped to 'borrowed' on disk) or null when none is available.
116
+ *
117
+ * The caller MUST then run the git ops to checkout the new branch, and call
118
+ * `evictEntry` on failure to release the slot.
119
+ */
120
+ function tryBorrow(projectName, dispatchId, opts) {
121
+ opts = opts || {};
122
+ if (!projectName || !dispatchId) return null;
123
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
124
+ let borrowed = null;
125
+ mutateWorktreePool((data) => {
126
+ const entries = data.entries;
127
+ // Pick the most-recently-used idle entry for this project (warmer caches).
128
+ let bestIdx = -1;
129
+ let bestIdleSince = -Infinity;
130
+ for (let i = 0; i < entries.length; i++) {
131
+ const e = entries[i];
132
+ if (!e || e.project !== projectName || e.state !== POOL_STATE.IDLE) continue;
133
+ // Skip entries whose path no longer exists on disk.
134
+ if (!e.path || !fs.existsSync(e.path)) continue;
135
+ const t = Date.parse(e.idleSince || e.lastUsed || e.createdAt || '') || 0;
136
+ if (t > bestIdleSince) { bestIdleSince = t; bestIdx = i; }
137
+ }
138
+ if (bestIdx === -1) return data;
139
+ const entry = entries[bestIdx];
140
+ entry.state = POOL_STATE.BORROWED;
141
+ entry.borrowedBy = dispatchId;
142
+ entry.borrowedAt = _nowIso(now);
143
+ entry.lastUsed = entry.borrowedAt;
144
+ entry.idleSince = null;
145
+ borrowed = { ...entry };
146
+ return data;
147
+ });
148
+ return borrowed;
149
+ }
150
+
151
+ /**
152
+ * Register a brand-new worktree as a borrowed pool member. Used when a fresh
153
+ * worktree is created (no idle slot available) and the pool has room — we
154
+ * track it from creation so the eventual return is a simple state flip.
155
+ *
156
+ * Honors capacity inside the locked mutation: if the project is already at or
157
+ * over `poolSize`, the registration is rejected and the worktree stays
158
+ * untracked (cleanup will reap it normally).
159
+ */
160
+ function registerBorrowed(projectName, wtPath, dispatchId, opts) {
161
+ opts = opts || {};
162
+ if (!projectName || !wtPath || !dispatchId) return false;
163
+ const poolSize = Number.isFinite(opts.poolSize) ? Math.max(0, Math.floor(opts.poolSize)) : 0;
164
+ if (poolSize <= 0) return false;
165
+ const branch = opts.branch || '';
166
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
167
+ let registered = false;
168
+ mutateWorktreePool((data) => {
169
+ const entries = data.entries;
170
+ // Already tracked? Update the borrower fields and keep going.
171
+ const existingIdx = _findEntryByPath(entries, wtPath);
172
+ if (existingIdx !== -1) {
173
+ const e = entries[existingIdx];
174
+ e.state = POOL_STATE.BORROWED;
175
+ e.borrowedBy = dispatchId;
176
+ e.borrowedAt = _nowIso(now);
177
+ e.idleSince = null;
178
+ if (branch) e.lastBranch = branch;
179
+ e.lastUsed = e.borrowedAt;
180
+ registered = true;
181
+ return data;
182
+ }
183
+ // Capacity gate — count entries (idle + borrowed) for this project.
184
+ const projectCount = entries.filter(e => e && e.project === projectName).length;
185
+ if (projectCount >= poolSize) return data;
186
+ entries.push({
187
+ project: projectName,
188
+ path: wtPath,
189
+ state: POOL_STATE.BORROWED,
190
+ borrowedBy: dispatchId,
191
+ borrowedAt: _nowIso(now),
192
+ idleSince: null,
193
+ lastBranch: branch,
194
+ createdAt: _nowIso(now),
195
+ lastUsed: _nowIso(now),
196
+ });
197
+ registered = true;
198
+ return data;
199
+ });
200
+ return registered;
201
+ }
202
+
203
+ /**
204
+ * Mark a borrowed entry as idle. Caller is responsible for having already run
205
+ * the git reset/clean/checkout-detach/pull dance — this is purely a state
206
+ * flip. If the entry is unknown, no-op (caller can decide to register first).
207
+ */
208
+ function markIdle(wtPath, opts) {
209
+ opts = opts || {};
210
+ if (!wtPath) return false;
211
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
212
+ const branch = opts.branch || '';
213
+ let flipped = false;
214
+ mutateWorktreePool((data) => {
215
+ const idx = _findEntryByPath(data.entries, wtPath);
216
+ if (idx === -1) return data;
217
+ const e = data.entries[idx];
218
+ e.state = POOL_STATE.IDLE;
219
+ e.borrowedBy = null;
220
+ e.borrowedAt = null;
221
+ e.idleSince = _nowIso(now);
222
+ e.lastUsed = _nowIso(now);
223
+ if (branch) e.lastBranch = branch;
224
+ flipped = true;
225
+ return data;
226
+ });
227
+ return flipped;
228
+ }
229
+
230
+ /**
231
+ * Return a worktree to the pool — flip an existing borrowed entry to idle, or
232
+ * insert a fresh entry as idle when the project is under capacity. Both paths
233
+ * enforce the per-project capacity inside the locked mutation, so two
234
+ * simultaneous returns cannot blow past `poolSize`.
235
+ *
236
+ * Returns one of:
237
+ * - 'flipped' — existing borrowed entry flipped to idle
238
+ * - 'inserted' — new idle entry added (was a fresh worktree)
239
+ * - 'rejected' — pool full or poolSize=0; caller should let normal cleanup
240
+ * reap the worktree
241
+ *
242
+ * Caller MUST have already run the git reset/clean/checkout-detach/pull dance
243
+ * before invoking this function. On any git failure earlier, call `evictEntry`
244
+ * and skip the return.
245
+ */
246
+ function returnToPool(projectName, wtPath, opts) {
247
+ opts = opts || {};
248
+ if (!projectName || !wtPath) return 'rejected';
249
+ const poolSize = Number.isFinite(opts.poolSize) ? Math.max(0, Math.floor(opts.poolSize)) : 0;
250
+ if (poolSize <= 0) return 'rejected';
251
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
252
+ const branch = opts.branch || '';
253
+ let outcome = 'rejected';
254
+ mutateWorktreePool((data) => {
255
+ const entries = data.entries;
256
+ const existingIdx = _findEntryByPath(entries, wtPath);
257
+ if (existingIdx !== -1) {
258
+ const e = entries[existingIdx];
259
+ e.state = POOL_STATE.IDLE;
260
+ e.borrowedBy = null;
261
+ e.borrowedAt = null;
262
+ e.idleSince = _nowIso(now);
263
+ e.lastUsed = _nowIso(now);
264
+ if (branch) e.lastBranch = branch;
265
+ outcome = 'flipped';
266
+ return data;
267
+ }
268
+ // Insert path — gate on capacity inside the lock.
269
+ const projectCount = entries.filter(e => e && e.project === projectName).length;
270
+ if (projectCount >= poolSize) {
271
+ outcome = 'rejected';
272
+ return data;
273
+ }
274
+ entries.push({
275
+ project: projectName,
276
+ path: wtPath,
277
+ state: POOL_STATE.IDLE,
278
+ borrowedBy: null,
279
+ borrowedAt: null,
280
+ idleSince: _nowIso(now),
281
+ lastBranch: branch,
282
+ createdAt: _nowIso(now),
283
+ lastUsed: _nowIso(now),
284
+ });
285
+ outcome = 'inserted';
286
+ return data;
287
+ });
288
+ return outcome;
289
+ }
290
+
291
+ /**
292
+ * Remove an entry from the pool. Used on git failure during borrow/return so
293
+ * the worktree falls back to normal cleanup ownership.
294
+ */
295
+ function evictEntry(wtPath, reason) {
296
+ if (!wtPath) return false;
297
+ const norm = _normalizePath(wtPath);
298
+ let removed = false;
299
+ mutateWorktreePool((data) => {
300
+ const before = data.entries.length;
301
+ data.entries = data.entries.filter(e => _normalizePath(e?.path) !== norm);
302
+ if (data.entries.length !== before) {
303
+ removed = true;
304
+ log('info', `worktree-pool: evicted ${wtPath} (${reason || 'unspecified'})`);
305
+ }
306
+ return data;
307
+ });
308
+ return removed;
309
+ }
310
+
311
+ /**
312
+ * Drop entries whose path is gone, whose borrower is no longer active, or
313
+ * whose idle TTL has expired. `activeDispatchIds` is a Set of dispatch ids
314
+ * currently in `dispatch.active`. Returns { dropped: [...], evicted: number }.
315
+ */
316
+ function pruneStale(opts) {
317
+ opts = opts || {};
318
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
319
+ const ttlMs = Number.isFinite(opts.idleTtlMs) ? opts.idleTtlMs : getPoolIdleTtlMs(opts.config);
320
+ const activeIds = opts.activeDispatchIds instanceof Set
321
+ ? opts.activeDispatchIds
322
+ : new Set(Array.isArray(opts.activeDispatchIds) ? opts.activeDispatchIds : []);
323
+ const dropped = [];
324
+ mutateWorktreePool((data) => {
325
+ const next = [];
326
+ for (const e of data.entries) {
327
+ if (!e || !e.path) { dropped.push({ path: e?.path, reason: 'invalid' }); continue; }
328
+ if (!fs.existsSync(e.path)) { dropped.push({ path: e.path, reason: 'missing-path' }); continue; }
329
+ if (e.state === POOL_STATE.BORROWED) {
330
+ if (!e.borrowedBy || !activeIds.has(e.borrowedBy)) {
331
+ dropped.push({ path: e.path, reason: 'orphan-borrow' });
332
+ continue;
333
+ }
334
+ next.push(e);
335
+ continue;
336
+ }
337
+ if (e.state === POOL_STATE.IDLE) {
338
+ const idleAt = Date.parse(e.idleSince || e.lastUsed || e.createdAt || '') || 0;
339
+ if (idleAt > 0 && (now - idleAt) > ttlMs) {
340
+ dropped.push({ path: e.path, reason: 'ttl-expired' });
341
+ continue;
342
+ }
343
+ next.push(e);
344
+ continue;
345
+ }
346
+ // Unknown state — drop defensively.
347
+ dropped.push({ path: e.path, reason: 'unknown-state' });
348
+ }
349
+ data.entries = next;
350
+ return data;
351
+ });
352
+ return { dropped, evicted: dropped.length };
353
+ }
354
+
355
+ /**
356
+ * Count entries (idle + borrowed) belonging to a project. Used to gate
357
+ * `markIdle` registrations from fresh-create paths.
358
+ */
359
+ function countForProject(projectName) {
360
+ if (!projectName) return 0;
361
+ return _entriesArray(readPool()).filter(e => e && e.project === projectName).length;
362
+ }
363
+
364
+ /**
365
+ * Return the set of borrowed pool paths whose borrower is in `activeIds`.
366
+ * Used by cleanup.js to protect them from the age/cap sweep.
367
+ */
368
+ function getActiveBorrowedPaths(activeDispatchIds) {
369
+ const activeIds = activeDispatchIds instanceof Set
370
+ ? activeDispatchIds
371
+ : new Set(Array.isArray(activeDispatchIds) ? activeDispatchIds : []);
372
+ const out = new Set();
373
+ for (const e of _entriesArray(readPool())) {
374
+ if (!e || e.state !== POOL_STATE.BORROWED) continue;
375
+ if (e.borrowedBy && activeIds.has(e.borrowedBy)) out.add(_normalizePath(e.path));
376
+ }
377
+ return out;
378
+ }
379
+
380
+ function getIdlePaths() {
381
+ const out = new Set();
382
+ for (const e of _entriesArray(readPool())) {
383
+ if (!e || e.state !== POOL_STATE.IDLE) continue;
384
+ out.add(_normalizePath(e.path));
385
+ }
386
+ return out;
387
+ }
388
+
389
+ module.exports = {
390
+ WORKTREE_POOL_FILENAME,
391
+ POOL_STATE,
392
+ getPoolPath,
393
+ readPool,
394
+ mutateWorktreePool,
395
+ getProjectPoolSize,
396
+ getPoolIdleTtlMs,
397
+ isPoolMember,
398
+ getEntry,
399
+ tryBorrow,
400
+ registerBorrowed,
401
+ markIdle,
402
+ returnToPool,
403
+ evictEntry,
404
+ pruneStale,
405
+ countForProject,
406
+ getActiveBorrowedPaths,
407
+ getIdlePaths,
408
+ // Exposed for test injection only — not part of public contract.
409
+ _normalizePath,
410
+ };