@yemi33/minions 0.1.2372 → 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.
@@ -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
+ };