@yemi33/minions 0.1.2371 → 0.1.2373
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/settings.js +1 -0
- package/dashboard.js +127 -112
- package/docs/live-checkout-mode.md +27 -0
- package/docs/runtime-adapters.md +103 -0
- package/engine/acp-transport.js +594 -0
- package/engine/agent-worker-pool.js +367 -0
- package/engine/cc-worker-pool.js +38 -563
- package/engine/cleanup.js +34 -19
- package/engine/cli.js +19 -0
- package/engine/live-checkout.js +168 -0
- package/engine/pooled-agent-process.js +158 -0
- package/engine/runtimes/copilot.js +5 -0
- package/engine/shared.js +75 -1
- package/engine/timeout.js +51 -7
- package/engine.js +81 -7
- package/package.json +1 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/agent-worker-pool.js — Fixed-size ACP worker pool for fleet agent
|
|
3
|
+
* dispatches (P-4c6e8a72, W-mre7fhxz001ka5fa / P-9b3d5f61 plan).
|
|
4
|
+
*
|
|
5
|
+
* Modeled on engine/cc-worker-pool.js but with a DIFFERENT ownership model:
|
|
6
|
+
* cc-worker-pool.js gives each CC/doc-chat tab a permanently-owned worker
|
|
7
|
+
* (tabId → Worker, kept warm for the tab's lifetime); this pool instead
|
|
8
|
+
* leases a worker to exactly one active fleet dispatch at a time and
|
|
9
|
+
* returns it to a shared free set on release — 1 worker : 1 ACTIVE dispatch,
|
|
10
|
+
* not 1 worker : 1 dispatch for life. Both pools share the same underlying
|
|
11
|
+
* spawn + JSON-RPC transport (engine/acp-transport.js) — no duplicated
|
|
12
|
+
* framing code.
|
|
13
|
+
*
|
|
14
|
+
* Public API:
|
|
15
|
+
*
|
|
16
|
+
* pool.acquireWorker({ dispatchId, cwd, model, effort, mcpServers, hermeticDirs })
|
|
17
|
+
* → Promise<WorkerHandle>. Blocks (FIFO queue) when no free/matching
|
|
18
|
+
* worker exists and the pool is already at its configured size.
|
|
19
|
+
* WorkerHandle: {
|
|
20
|
+
* dispatchId,
|
|
21
|
+
* stream(promptText, opts) → Promise, // ACP session/prompt turn
|
|
22
|
+
* cancel(), // ACP session/cancel
|
|
23
|
+
* release(), // return the worker to the
|
|
24
|
+
* // free set (or evict it, if
|
|
25
|
+
* // it died during the lease)
|
|
26
|
+
* }
|
|
27
|
+
* pool.setPoolSize(n) / getPoolSize()
|
|
28
|
+
* pool.shutdown() // close every worker, reject the queue
|
|
29
|
+
*
|
|
30
|
+
* Assignment strategy (acquireWorker), per the P-7a1c2e4f spike's findings:
|
|
31
|
+
*
|
|
32
|
+
* 1. A FREE worker whose mcpServersHash already matches the request wins
|
|
33
|
+
* first — reused via a fresh `session/new` (worker.newSession), no
|
|
34
|
+
* respawn, no wasted initialize handshake.
|
|
35
|
+
* 2. Otherwise any OTHER free worker is evicted (closed) and a replacement
|
|
36
|
+
* is spawned fresh with the requested mcpServers/cwd — MCP servers are
|
|
37
|
+
* resolved at `copilot --acp` process boot (via $COPILOT_HOME), so a
|
|
38
|
+
* changed local/stdio server set requires a full respawn, not just a
|
|
39
|
+
* new session (spike (c)).
|
|
40
|
+
* 3. Otherwise, if the pool has room (fewer than `poolSize` workers
|
|
41
|
+
* allocated across free+busy+in-flight-spawn), spawn a brand new
|
|
42
|
+
* worker.
|
|
43
|
+
* 4. Otherwise the request queues in FIFO order until a `release()` or a
|
|
44
|
+
* worker crash frees a slot.
|
|
45
|
+
*
|
|
46
|
+
* A worker that exits its OS process after the ACP handshake (WORKER_DIED)
|
|
47
|
+
* is evicted immediately — from wherever it currently lives (free set or an
|
|
48
|
+
* active lease) — and is NEVER returned to the free set; the freed slot
|
|
49
|
+
* immediately unblocks the next queued acquireWorker (pump re-runs on every
|
|
50
|
+
* eviction and every release).
|
|
51
|
+
*
|
|
52
|
+
* `hermeticDirs` (workspace-manifest `allowed_repos` beyond the primary
|
|
53
|
+
* `cwd`) has no `session/new` parameter (spike (b) confirmed this) — it is
|
|
54
|
+
* applied by sending the ACP `/add-dir <path>` runtime slash command as a
|
|
55
|
+
* `session/prompt` turn for each dir, immediately after the session is
|
|
56
|
+
* established and before the handle is returned to the caller.
|
|
57
|
+
*
|
|
58
|
+
* No new npm deps — Node built-ins only.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const transport = require('./acp-transport');
|
|
62
|
+
const { Worker, hashMcpServers } = transport;
|
|
63
|
+
|
|
64
|
+
// Test seam — mirrors engine/cc-worker-pool.js's `_internals` pattern, but is
|
|
65
|
+
// its OWN object (constructor-injected into Worker per acquisition) so this
|
|
66
|
+
// pool's test stubs never collide with cc-worker-pool.js's.
|
|
67
|
+
const _internals = {
|
|
68
|
+
spawnAcp: transport.spawnAcp,
|
|
69
|
+
killImmediate: transport.killImmediate,
|
|
70
|
+
now() { return Date.now(); },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// P-9b3d5f61 — falls back to resolveAgentAcpPoolSize(engine)'s own default
|
|
74
|
+
// chain (engine.agentAcpPoolSize > engine.maxConcurrent >
|
|
75
|
+
// ENGINE_DEFAULTS.maxConcurrent) once the spawn-routing item (P-1d8f0b93)
|
|
76
|
+
// wires `setPoolSize` in on config load. ENGINE_DEFAULTS.maxConcurrent is 5
|
|
77
|
+
// (engine/shared.js) — mirrored here as the pre-wiring default so a caller
|
|
78
|
+
// exercising this module directly (e.g. tests) gets a sane cap.
|
|
79
|
+
const DEFAULT_POOL_SIZE = 5;
|
|
80
|
+
let _poolSize = DEFAULT_POOL_SIZE;
|
|
81
|
+
|
|
82
|
+
function setPoolSize(n) {
|
|
83
|
+
const parsed = Number(n);
|
|
84
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return _poolSize;
|
|
85
|
+
_poolSize = Math.floor(parsed);
|
|
86
|
+
_pump();
|
|
87
|
+
return _poolSize;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getPoolSize() {
|
|
91
|
+
return _poolSize;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// AGENT_POOL_TRACE-gated structured trace logger — mirrors cc-worker-pool.js's
|
|
95
|
+
// CC_POOL_TRACE convention but under its own env var so the two pools can be
|
|
96
|
+
// traced independently. NO PII — dispatchId (caller-supplied), sessionIds
|
|
97
|
+
// (opaque ACP ids), and protocol flags only.
|
|
98
|
+
function _trace(...parts) {
|
|
99
|
+
if (!process.env.AGENT_POOL_TRACE) return;
|
|
100
|
+
try { process.stderr.write('[agent-pool] ' + parts.join(' ') + '\n'); }
|
|
101
|
+
catch { /* swallow telemetry errors */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let _nextWorkerId = 1;
|
|
105
|
+
|
|
106
|
+
const _free = []; // Worker[] — idle, unleased
|
|
107
|
+
const _busy = new Map(); // dispatchId → Worker — currently leased
|
|
108
|
+
const _queue = []; // pending acquire requests, FIFO
|
|
109
|
+
// Slots reserved for a worker that's mid-reuse/respawn/fresh-spawn — pulled
|
|
110
|
+
// out of `_free` (or not yet spawned) but not yet placed in `_busy`. Counted
|
|
111
|
+
// toward `_poolSize` so a burst of concurrent acquireWorker calls can't
|
|
112
|
+
// overshoot the configured pool size while spawns are still in flight.
|
|
113
|
+
let _reserved = 0;
|
|
114
|
+
|
|
115
|
+
function _occupied() {
|
|
116
|
+
return _free.length + _busy.size + _reserved;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Queue pump ──────────────────────────────────────────────────────────
|
|
120
|
+
//
|
|
121
|
+
// Fully synchronous per pass: each iteration either satisfies the head of
|
|
122
|
+
// the queue by reserving capacity synchronously (splice out of `_free`, or
|
|
123
|
+
// bump `_reserved`) and kicking off the (async, fire-and-forget from here)
|
|
124
|
+
// reuse/respawn/spawn work, or — if the queue's head can't be satisfied yet
|
|
125
|
+
// — stops. The async work calls `_pump()` again itself on completion
|
|
126
|
+
// (success moves the reservation into `_busy` and the freed capacity is a
|
|
127
|
+
// no-op re-pump; failure frees the reservation and DOES let another queued
|
|
128
|
+
// request through).
|
|
129
|
+
function _pump() {
|
|
130
|
+
while (_queue.length > 0) {
|
|
131
|
+
const req = _queue[0];
|
|
132
|
+
|
|
133
|
+
const matchIdx = _free.findIndex((w) => w.mcpServersHash === req.mcpServersHash);
|
|
134
|
+
if (matchIdx >= 0) {
|
|
135
|
+
_queue.shift();
|
|
136
|
+
const worker = _free.splice(matchIdx, 1)[0];
|
|
137
|
+
_reserved++;
|
|
138
|
+
_reuseWorker(worker, req);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (_free.length > 0) {
|
|
143
|
+
_queue.shift();
|
|
144
|
+
const stale = _free.shift();
|
|
145
|
+
_reserved++;
|
|
146
|
+
_respawnWorker(stale, req);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (_occupied() < _poolSize) {
|
|
151
|
+
_queue.shift();
|
|
152
|
+
_reserved++;
|
|
153
|
+
_spawnFreshWorker(req);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Pool is full and no free worker exists — leave req at the head of the
|
|
158
|
+
// queue; a release() or a worker-death eviction will re-pump.
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function _reuseWorker(worker, req) {
|
|
164
|
+
try {
|
|
165
|
+
await worker.newSession({ mcpServers: req.mcpServers, model: req.model, effort: req.effort, cwd: req.cwd });
|
|
166
|
+
_trace(`dispatch=${req.dispatchId} reuse: worker=${worker.id} sessionId=${worker.sessionId}`);
|
|
167
|
+
_reserved--;
|
|
168
|
+
req.resolve(worker);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
_reserved--;
|
|
171
|
+
// The worker died mid-newSession (or the daemon rejected the call) —
|
|
172
|
+
// it's already unusable; don't return it to `_free`.
|
|
173
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
174
|
+
req.reject(err);
|
|
175
|
+
_pump();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function _respawnWorker(stale, req) {
|
|
180
|
+
try { stale.close(); } catch { /* already torn down */ }
|
|
181
|
+
try {
|
|
182
|
+
const worker = await _bootWorker(req);
|
|
183
|
+
_reserved--;
|
|
184
|
+
req.resolve(worker);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
_reserved--;
|
|
187
|
+
req.reject(err);
|
|
188
|
+
_pump();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function _spawnFreshWorker(req) {
|
|
193
|
+
try {
|
|
194
|
+
const worker = await _bootWorker(req);
|
|
195
|
+
_reserved--;
|
|
196
|
+
req.resolve(worker);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
_reserved--;
|
|
199
|
+
req.reject(err);
|
|
200
|
+
_pump();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Spawn + initialize + session/new a brand-new Worker for `req`. Attaches
|
|
205
|
+
// the pool's own proc-exit eviction listener once the handshake succeeds —
|
|
206
|
+
// Worker's own internal exit handler (engine/acp-transport.js) already marks
|
|
207
|
+
// `.killed` and fails any pending/inflight calls; this listener runs after
|
|
208
|
+
// it (registered later on the same EventEmitter) and removes the worker from
|
|
209
|
+
// whichever pool collection it's currently sitting in, then re-pumps so a
|
|
210
|
+
// queued request can spawn its replacement.
|
|
211
|
+
async function _bootWorker(req) {
|
|
212
|
+
const worker = new Worker({
|
|
213
|
+
id: `agent-pool-${_nextWorkerId++}`,
|
|
214
|
+
model: req.model,
|
|
215
|
+
effort: req.effort,
|
|
216
|
+
mcpServers: req.mcpServers,
|
|
217
|
+
mcpServersHash: req.mcpServersHash,
|
|
218
|
+
cwd: req.cwd,
|
|
219
|
+
internals: _internals,
|
|
220
|
+
trace: _trace,
|
|
221
|
+
});
|
|
222
|
+
const initPromise = worker._spawnAndInit();
|
|
223
|
+
worker.initPromise = initPromise;
|
|
224
|
+
try {
|
|
225
|
+
await initPromise;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
try { worker.close(); } catch { /* already torn down */ }
|
|
228
|
+
throw err;
|
|
229
|
+
} finally {
|
|
230
|
+
worker.initPromise = null;
|
|
231
|
+
}
|
|
232
|
+
if (worker.proc) {
|
|
233
|
+
worker.proc.once('exit', () => _onWorkerExit(worker));
|
|
234
|
+
}
|
|
235
|
+
_trace(`dispatch=${req.dispatchId} spawn: worker=${worker.id} sessionId=${worker.sessionId}`);
|
|
236
|
+
return worker;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function _onWorkerExit(worker) {
|
|
240
|
+
let removed = false;
|
|
241
|
+
const freeIdx = _free.indexOf(worker);
|
|
242
|
+
if (freeIdx >= 0) {
|
|
243
|
+
_free.splice(freeIdx, 1);
|
|
244
|
+
removed = true;
|
|
245
|
+
}
|
|
246
|
+
for (const [dispatchId, w] of _busy) {
|
|
247
|
+
if (w === worker) {
|
|
248
|
+
_busy.delete(dispatchId);
|
|
249
|
+
removed = true;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (removed) _trace(`worker=${worker.id} exited — evicted from pool`);
|
|
254
|
+
// A slot may now be free (whether or not this worker was tracked in
|
|
255
|
+
// `_free`/`_busy` — e.g. it may have died mid-reuse/respawn, in which case
|
|
256
|
+
// the reserving call's own catch already decremented `_reserved` and
|
|
257
|
+
// re-pumped). Re-pumping here is always safe/idempotent.
|
|
258
|
+
_pump();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Apply workspace-manifest `allowed_repos` beyond `cwd` (P-4c6e8a72 /
|
|
262
|
+
// P-7a1c2e4f spike (b)) — there is no `session/new` param for this; the only
|
|
263
|
+
// mechanism is the ACP `/add-dir <path>` runtime slash command sent as an
|
|
264
|
+
// ordinary `session/prompt` turn. Best-effort per dir: a failure surfaces via
|
|
265
|
+
// `_trace` but does not fail the whole acquisition — the dispatch still gets
|
|
266
|
+
// a working session scoped to `cwd`, just without the extra directory.
|
|
267
|
+
async function _applyHermeticDirs(worker, hermeticDirs) {
|
|
268
|
+
if (!Array.isArray(hermeticDirs) || hermeticDirs.length === 0) return;
|
|
269
|
+
for (const dir of hermeticDirs) {
|
|
270
|
+
if (!dir) continue;
|
|
271
|
+
await new Promise((resolve) => {
|
|
272
|
+
worker.stream(`/add-dir ${dir}`, {
|
|
273
|
+
onDone: () => { _trace(`worker=${worker.id} add-dir ok: ${dir}`); resolve(); },
|
|
274
|
+
onError: (err) => { _trace(`worker=${worker.id} add-dir failed: ${dir} (${err && err.message})`); resolve(); },
|
|
275
|
+
}).catch(() => resolve());
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Public API ────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, hermeticDirs } = {}) {
|
|
283
|
+
if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
|
|
284
|
+
if (_busy.has(dispatchId)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`agent-worker-pool.acquireWorker: dispatchId ${dispatchId} already has an active worker ` +
|
|
287
|
+
'(1 worker : 1 active dispatch invariant — call release() before acquiring again)'
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const mcpServersHash = hashMcpServers(mcpServers);
|
|
292
|
+
const req = { dispatchId, cwd, model, effort, mcpServers, mcpServersHash };
|
|
293
|
+
|
|
294
|
+
const worker = await new Promise((resolve, reject) => {
|
|
295
|
+
req.resolve = resolve;
|
|
296
|
+
req.reject = reject;
|
|
297
|
+
_queue.push(req);
|
|
298
|
+
_pump();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await _applyHermeticDirs(worker, hermeticDirs);
|
|
302
|
+
_busy.set(dispatchId, worker);
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
dispatchId,
|
|
306
|
+
// P-1d8f0b93 — exposed so engine.js's PooledAgentProcess facade (the
|
|
307
|
+
// spawnAgent integration) can write the same PID-file/log identity a
|
|
308
|
+
// cold-spawned child_process would, and stamp the ACP sessionId into the
|
|
309
|
+
// synthesized `result` event for parseOutput parity. Both are read-time
|
|
310
|
+
// snapshots of the underlying worker's current values (a getter, not a
|
|
311
|
+
// captured copy), since sessionId can rotate under a reused worker.
|
|
312
|
+
get pid() { return (worker.proc && worker.proc.pid) || null; },
|
|
313
|
+
get sessionId() { return worker.sessionId || null; },
|
|
314
|
+
stream: (promptText, opts) => worker.stream(promptText, opts),
|
|
315
|
+
cancel: () => worker.cancel(),
|
|
316
|
+
release: () => release(dispatchId),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Return a leased worker to the free set — unless it died during the lease
|
|
321
|
+
// (WORKER_DIED), in which case it's simply dropped (already evicted from
|
|
322
|
+
// `_busy` by `_onWorkerExit` if the crash happened before release(), or
|
|
323
|
+
// dropped here if release() is the first to notice `.killed`). Returns
|
|
324
|
+
// false if `dispatchId` has no active lease (already released/evicted or
|
|
325
|
+
// never acquired) — safe to call defensively.
|
|
326
|
+
function release(dispatchId) {
|
|
327
|
+
const worker = _busy.get(dispatchId);
|
|
328
|
+
if (!worker) return false;
|
|
329
|
+
_busy.delete(dispatchId);
|
|
330
|
+
if (worker.killed) {
|
|
331
|
+
_trace(`dispatch=${dispatchId} release: worker=${worker.id} was dead — evicted, not returned to free set`);
|
|
332
|
+
_pump();
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
_free.push(worker);
|
|
336
|
+
_trace(`dispatch=${dispatchId} release: worker=${worker.id} returned to free set`);
|
|
337
|
+
_pump();
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function shutdown() {
|
|
342
|
+
for (const req of _queue.splice(0)) {
|
|
343
|
+
try { req.reject(new Error('agent-worker-pool: shutdown')); } catch { /* swallow */ }
|
|
344
|
+
}
|
|
345
|
+
for (const worker of _free.splice(0)) {
|
|
346
|
+
try { worker.close(); } catch { /* swallow */ }
|
|
347
|
+
}
|
|
348
|
+
for (const [, worker] of _busy) {
|
|
349
|
+
try { worker.close(); } catch { /* swallow */ }
|
|
350
|
+
}
|
|
351
|
+
_busy.clear();
|
|
352
|
+
_reserved = 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = {
|
|
356
|
+
acquireWorker,
|
|
357
|
+
release,
|
|
358
|
+
setPoolSize,
|
|
359
|
+
getPoolSize,
|
|
360
|
+
shutdown,
|
|
361
|
+
DEFAULT_POOL_SIZE,
|
|
362
|
+
// Exposed for unit tests; engine code MUST go through the public API.
|
|
363
|
+
_internals,
|
|
364
|
+
_free,
|
|
365
|
+
_busy,
|
|
366
|
+
_queue,
|
|
367
|
+
};
|