@yemi33/minions 0.1.1950 → 0.1.1952
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/command-center.js +13 -2
- package/dashboard/js/modal-qa.js +10 -0
- package/dashboard/js/refresh.js +4 -0
- package/dashboard/js/render-dispatch.js +25 -0
- package/dashboard/js/render-other.js +109 -2
- package/dashboard/js/settings.js +1 -1
- package/dashboard/layout.html +2 -2
- package/dashboard/pages/engine.html +6 -0
- package/dashboard/slim.html +1987 -0
- package/dashboard/styles.css +8 -0
- package/dashboard.js +450 -40
- package/docs/completion-reports.md +25 -0
- package/docs/design-state-storage.md +1 -1
- package/docs/slim-ux/architecture-suggestions.md +467 -0
- package/docs/slim-ux/concepts.md +824 -0
- package/engine/ado-mcp-wrapper.js +33 -7
- package/engine/ado.js +123 -15
- package/engine/cc-worker-pool.js +41 -0
- package/engine/cleanup.js +71 -34
- package/engine/cli.js +37 -0
- package/engine/dispatch.js +32 -9
- package/engine/features.js +6 -0
- package/engine/gh-token.js +137 -0
- package/engine/github.js +166 -29
- package/engine/issues.js +29 -0
- package/engine/keep-process-sweep.js +397 -0
- package/engine/lifecycle.js +150 -33
- package/engine/playbook.js +17 -0
- package/engine/queries.js +71 -0
- package/engine/recovery.js +6 -0
- package/engine/shared.js +446 -14
- package/engine/spawn-agent.js +44 -2
- package/engine/timeout.js +34 -11
- package/engine/worktree-pool.js +410 -0
- package/engine.js +643 -119
- package/package.json +6 -3
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +3 -1
- package/prompts/cc-system.md +24 -0
- package/engine/copilot-models.json +0 -5
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
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
+
};
|