@yemi33/minions 0.1.2382 → 0.1.2384

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 (51) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +2 -1
  3. package/dashboard/js/settings.js +1 -1
  4. package/dashboard.js +37 -19
  5. package/docs/completion-reports.md +20 -1
  6. package/docs/keep-processes.md +7 -2
  7. package/docs/live-checkout-mode.md +7 -7
  8. package/docs/managed-spawn.md +14 -1
  9. package/docs/runtime-adapters.md +61 -26
  10. package/docs/skills.md +2 -0
  11. package/docs/workspace-manifests.md +1 -1
  12. package/docs/worktree-lifecycle.md +36 -0
  13. package/engine/acp-transport.js +273 -58
  14. package/engine/ado-comment.js +3 -1
  15. package/engine/agent-worker-pool.js +278 -54
  16. package/engine/cc-worker-pool.js +12 -3
  17. package/engine/cleanup.js +15 -11
  18. package/engine/cli.js +56 -28
  19. package/engine/comment-format.js +51 -14
  20. package/engine/create-pr-worktree.js +57 -79
  21. package/engine/dispatch.js +7 -2
  22. package/engine/execution-model.js +68 -0
  23. package/engine/gh-comment.js +6 -3
  24. package/engine/github.js +6 -7
  25. package/engine/keep-process-sweep.js +131 -9
  26. package/engine/lifecycle.js +126 -45
  27. package/engine/live-checkout.js +4 -2
  28. package/engine/llm.js +59 -83
  29. package/engine/managed-spawn.js +112 -5
  30. package/engine/memory-store.js +3 -1
  31. package/engine/playbook.js +4 -6
  32. package/engine/pooled-agent-process.js +512 -45
  33. package/engine/pr-action.js +6 -8
  34. package/engine/process-utils.js +154 -18
  35. package/engine/runtimes/claude.js +94 -25
  36. package/engine/runtimes/codex.js +1 -0
  37. package/engine/runtimes/contract.js +239 -0
  38. package/engine/runtimes/copilot.js +97 -9
  39. package/engine/runtimes/index.js +37 -9
  40. package/engine/shared.js +349 -82
  41. package/engine/spawn-agent.js +85 -116
  42. package/engine/spawn-phase-watchdog.js +8 -37
  43. package/engine/timeout.js +14 -10
  44. package/engine/worktree-gc.js +55 -46
  45. package/engine.js +418 -241
  46. package/package.json +1 -1
  47. package/playbooks/fix.md +20 -0
  48. package/playbooks/review.md +2 -0
  49. package/playbooks/shared-rules.md +2 -2
  50. package/prompts/cc-system.md +11 -11
  51. package/skills/check-self-authored-review-comment/SKILL.md +34 -0
@@ -18,6 +18,7 @@
18
18
  * worker exists and the pool is already at its configured size.
19
19
  * WorkerHandle: {
20
20
  * dispatchId,
21
+ * currentModel, // ACP-selected session model
21
22
  * stream(promptText, opts) → Promise, // ACP session/prompt turn
22
23
  * cancel(), // ACP session/cancel
23
24
  * release(), // return the worker to the
@@ -29,14 +30,15 @@
29
30
  *
30
31
  * Assignment strategy (acquireWorker), per the P-7a1c2e4f spike's findings:
31
32
  *
32
- * 1. A FREE worker whose mcpServersHash already matches the request wins
33
+ * 1. A FREE worker whose MCP config, process environment, and process cwd
34
+ * match the request wins
33
35
  * first — reused via a fresh `session/new` (worker.newSession), no
34
36
  * respawn, no wasted initialize handshake.
35
37
  * 2. Otherwise any OTHER free worker is evicted (closed) and a replacement
36
38
  * is spawned fresh with the requested mcpServers/cwd — MCP servers are
37
39
  * 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
+ * changed local/stdio server set or process cwd requires a full respawn,
41
+ * not just a new session (spike (c)).
40
42
  * 3. Otherwise, if the pool has room (fewer than `poolSize` workers
41
43
  * allocated across free+busy+in-flight-spawn), spawn a brand new
42
44
  * worker.
@@ -58,7 +60,9 @@
58
60
  * No new npm deps — Node built-ins only.
59
61
  */
60
62
 
63
+ const path = require('path');
61
64
  const transport = require('./acp-transport');
65
+ const { buildWorkerArgs } = require('./runtimes');
62
66
  const { Worker, hashMcpServers, hashProcessContext } = transport;
63
67
 
64
68
  // Test seam — mirrors engine/cc-worker-pool.js's `_internals` pattern, but is
@@ -67,6 +71,11 @@ const { Worker, hashMcpServers, hashProcessContext } = transport;
67
71
  const _internals = {
68
72
  spawnAcp: transport.spawnAcp,
69
73
  killImmediate: transport.killImmediate,
74
+ killRoot: transport.killRoot,
75
+ async getProcessStartedAt(pid) {
76
+ const processes = await require('./shared').listAllProcessesAsync();
77
+ return Number(processes.find((entry) => entry.pid === pid)?.startedAt) || 0;
78
+ },
70
79
  now() { return Date.now(); },
71
80
  };
72
81
 
@@ -81,8 +90,10 @@ let _poolSize = DEFAULT_POOL_SIZE;
81
90
 
82
91
  function setPoolSize(n) {
83
92
  const parsed = Number(n);
84
- if (!Number.isFinite(parsed) || parsed <= 0) return _poolSize;
85
- _poolSize = Math.floor(parsed);
93
+ const nextSize = Math.floor(parsed);
94
+ if (!Number.isFinite(parsed) || nextSize <= 0) return _poolSize;
95
+ _poolSize = nextSize;
96
+ _trimFreeWorkers();
86
97
  _pump();
87
98
  return _poolSize;
88
99
  }
@@ -107,17 +118,57 @@ const _free = []; // Worker[] — idle, unleased
107
118
  const _busy = new Map(); // dispatchId → Worker — currently leased
108
119
  const _queue = []; // pending acquire requests, FIFO
109
120
  const _leaseContexts = new Map(); // dispatchId → mutable session-only context
121
+ const _acquiring = new Map(); // dispatchId → queued/reserved/setup request
122
+ const _starting = new Set(); // Worker instances still in ACP handshake
110
123
  // Slots reserved for a worker that's mid-reuse/respawn/fresh-spawn — pulled
111
124
  // out of `_free` (or not yet spawned) but not yet placed in `_busy`. Counted
112
125
  // toward `_poolSize` so a burst of concurrent acquireWorker calls can't
113
126
  // overshoot the configured pool size while spawns are still in flight.
114
127
  let _reserved = 0;
115
128
  let _draining = false;
129
+ let _enabled = true;
116
130
 
117
131
  function _occupied() {
118
132
  return _free.length + _busy.size + _reserved;
119
133
  }
120
134
 
135
+ function _pathKey(value) {
136
+ const resolved = path.resolve(value || process.cwd());
137
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
138
+ }
139
+
140
+ function _closeWorker(worker) {
141
+ try {
142
+ if (worker.processStartedAt > 0) worker.close();
143
+ else worker.abandon();
144
+ } catch { /* already torn down */ }
145
+ }
146
+
147
+ function _trimFreeWorkers() {
148
+ while (_free.length > 0 && _occupied() > _poolSize) {
149
+ _closeWorker(_free.pop());
150
+ }
151
+ }
152
+
153
+ function _settleRequest(req, method, value) {
154
+ if (req.settled) return false;
155
+ req.settled = true;
156
+ try { req[method](value); } catch { /* already settled */ }
157
+ return true;
158
+ }
159
+
160
+ function _releaseReservation(req) {
161
+ if (!req.reserved) return;
162
+ req.reserved = false;
163
+ _reserved = Math.max(0, _reserved - 1);
164
+ }
165
+
166
+ function _acquisitionUnavailableError(req) {
167
+ if (req?.cancelled) return new Error('agent-worker-pool: acquisition cancelled');
168
+ if (_draining) return new Error('agent-worker-pool: draining');
169
+ return new Error('agent-worker-pool: disabled');
170
+ }
171
+
121
172
  // ── Queue pump ──────────────────────────────────────────────────────────
122
173
  //
123
174
  // Fully synchronous per pass: each iteration either satisfies the head of
@@ -129,17 +180,48 @@ function _occupied() {
129
180
  // no-op re-pump; failure frees the reservation and DOES let another queued
130
181
  // request through).
131
182
  function _pump() {
183
+ if (_draining || !_enabled) return;
132
184
  while (_queue.length > 0) {
133
185
  const req = _queue[0];
134
186
 
135
- const matchIdx = _free.findIndex((w) =>
136
- w.mcpServersHash === req.mcpServersHash
187
+ const compatible = (w) => (
188
+ w.runtime === req.runtime
189
+ && w.mcpServersHash === req.mcpServersHash
137
190
  && w.processContextHash === req.processContextHash
191
+ && w.processCwdKey === req.processCwdKey
138
192
  );
193
+ if (req.resumeSessionId) {
194
+ const busyOwnsSession = [..._busy.values()].some((worker) =>
195
+ worker.runtime === req.runtime && worker.sessionId === req.resumeSessionId);
196
+ const acquiringOwnsSession = [..._acquiring.values()].some((other) =>
197
+ other !== req
198
+ && other.reserved
199
+ && other.runtime === req.runtime
200
+ && (other.resumeSessionId === req.resumeSessionId || other.worker?.sessionId === req.resumeSessionId));
201
+ if (busyOwnsSession || acquiringOwnsSession) break;
202
+
203
+ const sessionOwnerIdx = _free.findIndex((worker) =>
204
+ worker.runtime === req.runtime && worker.sessionId === req.resumeSessionId);
205
+ if (sessionOwnerIdx >= 0) {
206
+ _queue.shift();
207
+ const sessionOwner = _free.splice(sessionOwnerIdx, 1)[0];
208
+ _reserved++;
209
+ req.reserved = true;
210
+ if (compatible(sessionOwner) && _pathKey(sessionOwner.cwd) === _pathKey(req.cwd)) {
211
+ _reuseWorker(sessionOwner, req);
212
+ } else {
213
+ _respawnWorker(sessionOwner, req);
214
+ }
215
+ continue;
216
+ }
217
+ }
218
+
219
+ const matchIdx = _free.findIndex(compatible);
139
220
  if (matchIdx >= 0) {
140
221
  _queue.shift();
141
222
  const worker = _free.splice(matchIdx, 1)[0];
142
223
  _reserved++;
224
+ req.reserved = true;
143
225
  _reuseWorker(worker, req);
144
226
  continue;
145
227
  }
@@ -148,6 +230,7 @@ function _pump() {
148
230
  _queue.shift();
149
231
  const stale = _free.shift();
150
232
  _reserved++;
233
+ req.reserved = true;
151
234
  _respawnWorker(stale, req);
152
235
  continue;
153
236
  }
@@ -155,6 +238,7 @@ function _pump() {
155
238
  if (_occupied() < _poolSize) {
156
239
  _queue.shift();
157
240
  _reserved++;
241
+ req.reserved = true;
158
242
  _spawnFreshWorker(req);
159
243
  continue;
160
244
  }
@@ -166,40 +250,55 @@ function _pump() {
166
250
  }
167
251
 
168
252
  async function _reuseWorker(worker, req) {
253
+ req.worker = worker;
169
254
  try {
170
- await worker.newSession({ mcpServers: req.mcpServers, model: req.model, effort: req.effort, cwd: req.cwd });
255
+ if (req.resumeSessionId) {
256
+ await worker.loadSession(req.resumeSessionId, {
257
+ mcpServers: req.mcpServers,
258
+ model: req.model,
259
+ effort: req.effort,
260
+ cwd: req.cwd,
261
+ });
262
+ } else {
263
+ await worker.newSession({
264
+ mcpServers: req.mcpServers,
265
+ model: req.model,
266
+ effort: req.effort,
267
+ cwd: req.cwd,
268
+ });
269
+ }
171
270
  _trace(`dispatch=${req.dispatchId} reuse: worker=${worker.id} sessionId=${worker.sessionId}`);
172
- _reserved--;
173
- if (_draining) {
174
- try { worker.close(); } catch { /* already torn down */ }
175
- req.reject(new Error('agent-worker-pool: draining'));
271
+ _releaseReservation(req);
272
+ if (req.cancelled || _draining || !_enabled) {
273
+ _closeWorker(worker);
274
+ _settleRequest(req, 'reject', _acquisitionUnavailableError(req));
176
275
  return;
177
276
  }
178
- req.resolve(worker);
277
+ _settleRequest(req, 'resolve', worker);
179
278
  } catch (err) {
180
- _reserved--;
279
+ _releaseReservation(req);
181
280
  // The worker died mid-newSession (or the daemon rejected the call) —
182
281
  // it's already unusable; don't return it to `_free`.
183
- try { worker.close(); } catch { /* already torn down */ }
184
- req.reject(err);
282
+ _closeWorker(worker);
283
+ _settleRequest(req, 'reject', err);
185
284
  _pump();
186
285
  }
187
286
  }
188
287
 
189
288
  async function _respawnWorker(stale, req) {
190
- try { stale.close(); } catch { /* already torn down */ }
289
+ _closeWorker(stale);
191
290
  try {
192
291
  const worker = await _bootWorker(req);
193
- _reserved--;
194
- if (_draining) {
195
- try { worker.close(); } catch { /* already torn down */ }
196
- req.reject(new Error('agent-worker-pool: draining'));
292
+ _releaseReservation(req);
293
+ if (req.cancelled || _draining || !_enabled) {
294
+ _closeWorker(worker);
295
+ _settleRequest(req, 'reject', _acquisitionUnavailableError(req));
197
296
  return;
198
297
  }
199
- req.resolve(worker);
298
+ _settleRequest(req, 'resolve', worker);
200
299
  } catch (err) {
201
- _reserved--;
202
- req.reject(err);
300
+ _releaseReservation(req);
301
+ _settleRequest(req, 'reject', err);
203
302
  _pump();
204
303
  }
205
304
  }
@@ -207,16 +306,16 @@ async function _respawnWorker(stale, req) {
207
306
  async function _spawnFreshWorker(req) {
208
307
  try {
209
308
  const worker = await _bootWorker(req);
210
- _reserved--;
211
- if (_draining) {
212
- try { worker.close(); } catch { /* already torn down */ }
213
- req.reject(new Error('agent-worker-pool: draining'));
309
+ _releaseReservation(req);
310
+ if (req.cancelled || _draining || !_enabled) {
311
+ _closeWorker(worker);
312
+ _settleRequest(req, 'reject', _acquisitionUnavailableError(req));
214
313
  return;
215
314
  }
216
- req.resolve(worker);
315
+ _settleRequest(req, 'resolve', worker);
217
316
  } catch (err) {
218
- _reserved--;
219
- req.reject(err);
317
+ _releaseReservation(req);
318
+ _settleRequest(req, 'reject', err);
220
319
  _pump();
221
320
  }
222
321
  }
@@ -229,7 +328,11 @@ async function _spawnFreshWorker(req) {
229
328
  // whichever pool collection it's currently sitting in, then re-pumps so a
230
329
  // queued request can spawn its replacement.
231
330
  async function _bootWorker(req) {
331
+ if (req.cancelled || _draining || !_enabled) {
332
+ throw _acquisitionUnavailableError(req);
333
+ }
232
334
  const worker = new Worker({
335
+ runtime: req.runtime,
233
336
  id: `agent-pool-${_nextWorkerId++}`,
234
337
  model: req.model,
235
338
  effort: req.effort,
@@ -237,19 +340,33 @@ async function _bootWorker(req) {
237
340
  mcpServersHash: req.mcpServersHash,
238
341
  processEnv: req.processEnv,
239
342
  processContextHash: req.processContextHash,
343
+ workerOptions: req.workerOptions,
344
+ resumeSessionId: req.resumeSessionId,
240
345
  cwd: req.cwd,
346
+ processCwd: req.processCwd,
241
347
  internals: _internals,
242
348
  trace: _trace,
243
349
  });
350
+ worker.processCwdKey = req.processCwdKey;
351
+ req.worker = worker;
352
+ _starting.add(worker);
244
353
  const initPromise = worker._spawnAndInit();
354
+ const identityPromise = worker.proc?.pid
355
+ ? _internals.getProcessStartedAt(worker.proc.pid)
356
+ : Promise.resolve(0);
245
357
  worker.initPromise = initPromise;
246
358
  try {
247
- await initPromise;
359
+ const [, processStartedAt] = await Promise.all([initPromise, identityPromise]);
360
+ if (worker.killed || processStartedAt <= 0) {
361
+ throw new Error('agent-worker-pool: worker process identity unavailable after startup');
362
+ }
363
+ worker.processStartedAt = processStartedAt;
248
364
  } catch (err) {
249
- try { worker.close(); } catch { /* already torn down */ }
365
+ _closeWorker(worker);
250
366
  throw err;
251
367
  } finally {
252
368
  worker.initPromise = null;
369
+ _starting.delete(worker);
253
370
  }
254
371
  if (worker.proc) {
255
372
  worker.proc.once('exit', () => _onWorkerExit(worker));
@@ -312,11 +429,13 @@ function _clearLeaseContext(dispatchId) {
312
429
  }
313
430
 
314
431
  async function acquireWorker({
315
- dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
432
+ runtime, dispatchId, cwd, processCwd, model, effort, mcpServers, hermeticDirs,
433
+ processEnv, workerOptions, sessionId, sessionContext,
316
434
  } = {}) {
317
435
  if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
318
436
  if (_draining) throw new Error('agent-worker-pool: draining');
319
- if (_busy.has(dispatchId) || _leaseContexts.has(dispatchId)) {
437
+ if (!_enabled) throw new Error('agent-worker-pool: disabled');
438
+ if (_busy.has(dispatchId) || _leaseContexts.has(dispatchId) || _acquiring.has(dispatchId)) {
320
439
  throw new Error(
321
440
  `agent-worker-pool.acquireWorker: dispatchId ${dispatchId} already has an active worker ` +
322
441
  '(1 worker : 1 active dispatch invariant — call release() before acquiring again)'
@@ -324,18 +443,43 @@ async function acquireWorker({
324
443
  }
325
444
 
326
445
  const mcpServersHash = hashMcpServers(mcpServers);
446
+ const runtimeAdapter = transport.resolveWorkerRuntime(runtime);
327
447
  const workerProcessEnv = processEnv && typeof processEnv === 'object'
328
448
  ? Object.freeze({ ...processEnv })
329
449
  : undefined;
330
- const processContextHash = hashProcessContext(workerProcessEnv);
450
+ const frozenWorkerOptions = Object.freeze(
451
+ workerOptions && typeof workerOptions === 'object' ? { ...workerOptions } : {}
452
+ );
453
+ const normalizedSessionCwd = path.resolve(cwd || process.cwd());
454
+ const normalizedProcessCwd = path.resolve(processCwd || cwd || process.cwd());
455
+ const processCwdKey = _pathKey(normalizedProcessCwd);
456
+ const workerArgs = buildWorkerArgs(runtimeAdapter, {
457
+ ...frozenWorkerOptions,
458
+ cwd: normalizedProcessCwd,
459
+ env: workerProcessEnv,
460
+ });
461
+ const processContextHash = hashProcessContext(workerProcessEnv, {
462
+ runtime: runtimeAdapter.name,
463
+ workerArgs,
464
+ processCwd: processCwdKey,
465
+ });
331
466
  const leaseContext = sessionContext && typeof sessionContext === 'object'
332
467
  ? { ...sessionContext }
333
468
  : {};
334
469
  const req = {
335
- dispatchId, cwd, model, effort, mcpServers, mcpServersHash,
336
- processEnv: workerProcessEnv, processContextHash,
470
+ runtime: runtimeAdapter,
471
+ dispatchId, cwd: normalizedSessionCwd, processCwd: normalizedProcessCwd,
472
+ model, effort, mcpServers, mcpServersHash,
473
+ processEnv: workerProcessEnv, processContextHash, processCwdKey,
474
+ workerOptions: frozenWorkerOptions,
475
+ resumeSessionId: sessionId || null,
476
+ cancelled: false,
477
+ settled: false,
478
+ reserved: false,
479
+ worker: null,
337
480
  };
338
481
  _leaseContexts.set(dispatchId, leaseContext);
482
+ _acquiring.set(dispatchId, req);
339
483
 
340
484
  let worker;
341
485
  try {
@@ -347,30 +491,38 @@ async function acquireWorker({
347
491
  });
348
492
 
349
493
  if (_draining) throw new Error('agent-worker-pool: draining');
494
+ if (req.cancelled || !_enabled) throw new Error('agent-worker-pool: acquisition cancelled');
350
495
  _busy.set(dispatchId, worker);
351
496
  await _applyHermeticDirs(worker, hermeticDirs);
352
497
  if (worker.killed) throw new Error('agent-worker-pool: worker died during acquisition');
353
- if (_draining) throw new Error('agent-worker-pool: draining');
498
+ if (req.cancelled || _draining || !_enabled) {
499
+ throw _acquisitionUnavailableError(req);
500
+ }
354
501
  } catch (err) {
355
502
  if (_busy.get(dispatchId) === worker) _busy.delete(dispatchId);
356
503
  _clearLeaseContext(dispatchId);
357
504
  if (worker) {
358
- try { worker.close(); } catch { /* already torn down */ }
505
+ _closeWorker(worker);
359
506
  _pump();
360
507
  }
361
508
  throw err;
509
+ } finally {
510
+ _acquiring.delete(dispatchId);
362
511
  }
363
512
 
364
513
  return {
365
514
  dispatchId,
366
515
  // P-1d8f0b93 — exposed so engine.js's PooledAgentProcess facade (the
367
516
  // spawnAgent integration) can write the same PID-file/log identity a
368
- // cold-spawned child_process would, and stamp the ACP sessionId into the
369
- // synthesized `result` event for parseOutput parity. Both are read-time
517
+ // cold-spawned child_process would, stamp the ACP sessionId into the
518
+ // synthesized `result` event, and expose the model selected by session/new.
519
+ // All are read-time
370
520
  // snapshots of the underlying worker's current values (a getter, not a
371
521
  // captured copy), since sessionId can rotate under a reused worker.
372
522
  get pid() { return (worker.proc && worker.proc.pid) || null; },
523
+ get processStartedAt() { return worker.processStartedAt || 0; },
373
524
  get sessionId() { return worker.sessionId || null; },
525
+ get currentModel() { return worker.currentModel || null; },
374
526
  stream: async (promptText, opts = {}) => {
375
527
  try {
376
528
  return await worker.stream(promptText, {
@@ -386,9 +538,26 @@ async function acquireWorker({
386
538
  return worker.cancel();
387
539
  },
388
540
  release: () => release(dispatchId),
541
+ discard: () => discard(dispatchId),
542
+ abandon: () => abandon(dispatchId),
543
+ preserveDescendants: (pids) => worker.preserveDescendants(pids),
389
544
  };
390
545
  }
391
546
 
547
+ function cancelAcquire(dispatchId) {
548
+ const req = _acquiring.get(dispatchId);
549
+ if (!req) return false;
550
+ req.cancelled = true;
551
+ const queueIdx = _queue.indexOf(req);
552
+ if (queueIdx >= 0) _queue.splice(queueIdx, 1);
553
+ if (_busy.get(dispatchId) === req.worker) _busy.delete(dispatchId);
554
+ _clearLeaseContext(dispatchId);
555
+ if (req.worker) _closeWorker(req.worker);
556
+ _settleRequest(req, 'reject', new Error('agent-worker-pool: acquisition cancelled'));
557
+ _pump();
558
+ return true;
559
+ }
560
+
392
561
  // Return a leased worker to the free set — unless it died during the lease
393
562
  // (WORKER_DIED), in which case it's simply dropped (already evicted from
394
563
  // `_busy` by `_onWorkerExit` if the crash happened before release(), or
@@ -405,9 +574,18 @@ function release(dispatchId) {
405
574
  _pump();
406
575
  return true;
407
576
  }
408
- if (_draining) {
409
- try { worker.close(); } catch { /* already torn down */ }
410
- _trace(`dispatch=${dispatchId} release: draining, worker=${worker.id} closed`);
577
+
578
+ if (worker.inflight) {
579
+ _closeWorker(worker);
580
+ _trace(`dispatch=${dispatchId} release: worker=${worker.id} still had an inflight turn and was evicted`);
581
+ _pump();
582
+ return true;
583
+ }
584
+
585
+ if (_draining || !_enabled || _occupied() >= _poolSize) {
586
+ _closeWorker(worker);
587
+ _trace(`dispatch=${dispatchId} release: worker=${worker.id} closed instead of exceeding pool capacity`);
588
+ _pump();
411
589
  return true;
412
590
  }
413
591
  _free.push(worker);
@@ -416,14 +594,55 @@ function release(dispatchId) {
416
594
  return true;
417
595
  }
418
596
 
597
+ function discard(dispatchId) {
598
+ const worker = _busy.get(dispatchId);
599
+ _clearLeaseContext(dispatchId);
600
+ if (!worker) return false;
601
+ _busy.delete(dispatchId);
602
+ _closeWorker(worker);
603
+ _trace(`dispatch=${dispatchId} discard: worker=${worker.id} evicted`);
604
+ _pump();
605
+ return true;
606
+ }
607
+
608
+ function abandon(dispatchId) {
609
+ const worker = _busy.get(dispatchId);
610
+ _clearLeaseContext(dispatchId);
611
+ if (!worker) return false;
612
+ _busy.delete(dispatchId);
613
+ try { worker.abandon(); } catch { /* pipe already closed */ }
614
+ _trace(`dispatch=${dispatchId} abandon: worker=${worker.id} evicted without signaling an unverified PID`);
615
+ _pump();
616
+ return true;
617
+ }
618
+
619
+ function setEnabled(enabled) {
620
+ const next = enabled === true;
621
+ if (_enabled === next) return _enabled;
622
+ if (next && _draining) return false;
623
+ _enabled = next;
624
+ if (!_enabled) {
625
+ for (const dispatchId of [..._acquiring.keys()]) cancelAcquire(dispatchId);
626
+ for (const worker of _free.splice(0)) _closeWorker(worker);
627
+ } else {
628
+ _pump();
629
+ }
630
+ return _enabled;
631
+ }
632
+
633
+ function isEnabled() {
634
+ return _enabled;
635
+ }
636
+
419
637
  function beginDrain() {
420
638
  if (_draining) return;
421
639
  _draining = true;
422
640
  for (const req of _queue.splice(0)) {
423
- try { req.reject(new Error('agent-worker-pool: draining')); } catch { /* already settled */ }
641
+ _clearLeaseContext(req.dispatchId);
642
+ _settleRequest(req, 'reject', new Error('agent-worker-pool: draining'));
424
643
  }
425
644
  for (const worker of _free.splice(0)) {
426
- try { worker.close(); } catch { /* already torn down */ }
645
+ _closeWorker(worker);
427
646
  }
428
647
  }
429
648
 
@@ -441,26 +660,29 @@ function getActiveLeaseIds() {
441
660
 
442
661
  function shutdown() {
443
662
  _draining = true;
444
- for (const req of _queue.splice(0)) {
445
- _clearLeaseContext(req.dispatchId);
446
- try { req.reject(new Error('agent-worker-pool: shutdown')); } catch { /* swallow */ }
447
- }
663
+ _enabled = false;
664
+ for (const dispatchId of [..._acquiring.keys()]) cancelAcquire(dispatchId);
448
665
  for (const worker of _free.splice(0)) {
449
- try { worker.close(); } catch { /* swallow */ }
666
+ _closeWorker(worker);
450
667
  }
451
668
  for (const [, worker] of _busy) {
452
- try { worker.close(); } catch { /* swallow */ }
669
+ _closeWorker(worker);
453
670
  }
671
+ for (const worker of _starting) _closeWorker(worker);
454
672
  _busy.clear();
455
673
  for (const dispatchId of [..._leaseContexts.keys()]) _clearLeaseContext(dispatchId);
456
- _reserved = 0;
457
674
  }
458
675
 
459
676
  module.exports = {
460
677
  acquireWorker,
678
+ cancelAcquire,
461
679
  release,
680
+ discard,
681
+ abandon,
462
682
  setPoolSize,
463
683
  getPoolSize,
684
+ setEnabled,
685
+ isEnabled,
464
686
  beginDrain,
465
687
  isDraining,
466
688
  isDrained,
@@ -473,4 +695,6 @@ module.exports = {
473
695
  _busy,
474
696
  _queue,
475
697
  _leaseContexts,
698
+ _acquiring,
699
+ _starting,
476
700
  };
@@ -79,6 +79,7 @@ const {
79
79
  const _internals = {
80
80
  spawnAcp: transport.spawnAcp,
81
81
  killImmediate: transport.killImmediate,
82
+ killRoot: transport.killRoot,
82
83
  now() { return Date.now(); },
83
84
  };
84
85
 
@@ -151,8 +152,9 @@ function _trace(...parts) {
151
152
 
152
153
  // ── Public API ────────────────────────────────────────────────────────────
153
154
 
154
- async function getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
155
+ async function getSession({ runtime, tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
155
156
  if (!tabId) throw new Error('cc-worker-pool.getSession: tabId is required');
157
+ const runtimeAdapter = transport.resolveWorkerRuntime(runtime);
156
158
  const mcpServersHash = _hashMcpServers(mcpServers);
157
159
  let worker = _tabs.get(tabId);
158
160
  // Track which lifecycle path we took so the dashboard's [cc-timing] log can
@@ -195,7 +197,7 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
195
197
  if (worker.killed) {
196
198
  _tabs.delete(tabId);
197
199
  worker = null;
198
- } else if (worker.mcpServersHash !== mcpServersHash) {
200
+ } else if (worker.runtime !== runtimeAdapter || worker.mcpServersHash !== mcpServersHash) {
199
201
  // mcpServers shape changed → must respawn the proc; the daemon
200
202
  // resolves MCP server config at process boot, not per session.
201
203
  _tabs.delete(tabId);
@@ -206,7 +208,13 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
206
208
  // and create a fresh one. Saves the ~2.1 s initialize handshake.
207
209
  // Pass model/effort so the NEW session picks up any override the
208
210
  // caller has rotated in (Bug B / issue #2479).
209
- await worker.newSession({ mcpServers, systemPromptHash, model, effort });
211
+ try {
212
+ await worker.newSession({ mcpServers, systemPromptHash, model, effort });
213
+ } catch (err) {
214
+ if (_tabs.get(tabId) === worker) _tabs.delete(tabId);
215
+ try { worker.close(); } catch { /* already torn down */ }
216
+ throw err;
217
+ }
210
218
  worker.lastUsedAt = _internals.now();
211
219
  lifecycle = 'new-session';
212
220
  } else {
@@ -222,6 +230,7 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
222
230
 
223
231
  if (!worker) {
224
232
  worker = new Worker({
233
+ runtime: runtimeAdapter,
225
234
  id: tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
226
235
  internals: _internals, trace: _trace,
227
236
  });
package/engine/cleanup.js CHANGED
@@ -808,16 +808,20 @@ async function runCleanup(config, verbose = false) {
808
808
  // normalized cwd values from managed-process state and protect any
809
809
  // worktree dir that contains one. Cwd is optional per the schema, so
810
810
  // entries without cwd contribute nothing.
811
- const _managedSpawnCwds = [];
811
+ const _anchoredProcessCwds = [];
812
812
  try {
813
813
  const _ms = require('./managed-spawn');
814
814
  for (const rec of _ms.listManagedSpecs()) {
815
815
  if (rec && typeof rec.cwd === 'string' && rec.cwd.length > 0) {
816
- try { _managedSpawnCwds.push(path.resolve(rec.cwd)); }
816
+ try { _anchoredProcessCwds.push(shared.realPathForComparison(rec.cwd)); }
817
817
  catch (_e) { /* malformed cwd — skip */ }
818
818
  }
819
819
  }
820
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}`); }
821
825
 
822
826
  // Probe `git branch --show-current` for every worktree in chunks of 5.
823
827
  // Sequential probing was the dominant cost in the cleanup phase
@@ -886,20 +890,20 @@ async function runCleanup(config, verbose = false) {
886
890
  if (verbose) console.log(` Skipping worktree ${dir}: pool-borrowed by active dispatch`);
887
891
  }
888
892
 
889
- // W-mpbinmrh001907e9 managed-spawn cwd anchor protection.
890
- // Worktrees backing live managed-spawn cwds must outlive the
891
- // originating dispatch; the engine-owned services run inside them.
893
+ // Long-running process cwd anchor protection.
894
+ // Worktrees backing live managed_spawn or keep_processes cwds must outlive the
895
+ // originating dispatch.
892
896
  // Overrides merged-branch and age sweeps because the cwd record is
893
897
  // the authoritative signal that something live still uses the dir.
894
- if (_managedSpawnCwds.length > 0) {
895
- const _wtPathNorm = path.resolve(wtPath);
896
- const _wtPathPrefix = _wtPathNorm + path.sep;
897
- for (const cwd of _managedSpawnCwds) {
898
- if (cwd === _wtPathNorm || cwd.startsWith(_wtPathPrefix)) {
898
+ if (_anchoredProcessCwds.length > 0) {
899
+ for (const cwd of _anchoredProcessCwds) {
900
+ let anchored = false;
901
+ try { anchored = shared.isPathInsideOrEqual(cwd, wtPath); } catch {}
902
+ if (anchored) {
899
903
  isProtected = true;
900
904
  if (shouldClean) {
901
905
  shouldClean = false;
902
- if (verbose) console.log(` Skipping worktree ${dir}: managed-spawn cwd anchor`);
906
+ if (verbose) console.log(` Skipping worktree ${dir}: long-running process cwd anchor`);
903
907
  }
904
908
  break;
905
909
  }