@yemi33/minions 0.1.2383 → 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.
@@ -30,14 +30,15 @@
30
30
  *
31
31
  * Assignment strategy (acquireWorker), per the P-7a1c2e4f spike's findings:
32
32
  *
33
- * 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
34
35
  * first — reused via a fresh `session/new` (worker.newSession), no
35
36
  * respawn, no wasted initialize handshake.
36
37
  * 2. Otherwise any OTHER free worker is evicted (closed) and a replacement
37
38
  * is spawned fresh with the requested mcpServers/cwd — MCP servers are
38
39
  * resolved at `copilot --acp` process boot (via $COPILOT_HOME), so a
39
- * changed local/stdio server set requires a full respawn, not just a
40
- * 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)).
41
42
  * 3. Otherwise, if the pool has room (fewer than `poolSize` workers
42
43
  * allocated across free+busy+in-flight-spawn), spawn a brand new
43
44
  * worker.
@@ -59,7 +60,9 @@
59
60
  * No new npm deps — Node built-ins only.
60
61
  */
61
62
 
63
+ const path = require('path');
62
64
  const transport = require('./acp-transport');
65
+ const { buildWorkerArgs } = require('./runtimes');
63
66
  const { Worker, hashMcpServers, hashProcessContext } = transport;
64
67
 
65
68
  // Test seam — mirrors engine/cc-worker-pool.js's `_internals` pattern, but is
@@ -68,6 +71,11 @@ const { Worker, hashMcpServers, hashProcessContext } = transport;
68
71
  const _internals = {
69
72
  spawnAcp: transport.spawnAcp,
70
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
+ },
71
79
  now() { return Date.now(); },
72
80
  };
73
81
 
@@ -82,8 +90,10 @@ let _poolSize = DEFAULT_POOL_SIZE;
82
90
 
83
91
  function setPoolSize(n) {
84
92
  const parsed = Number(n);
85
- if (!Number.isFinite(parsed) || parsed <= 0) return _poolSize;
86
- _poolSize = Math.floor(parsed);
93
+ const nextSize = Math.floor(parsed);
94
+ if (!Number.isFinite(parsed) || nextSize <= 0) return _poolSize;
95
+ _poolSize = nextSize;
96
+ _trimFreeWorkers();
87
97
  _pump();
88
98
  return _poolSize;
89
99
  }
@@ -108,17 +118,57 @@ const _free = []; // Worker[] — idle, unleased
108
118
  const _busy = new Map(); // dispatchId → Worker — currently leased
109
119
  const _queue = []; // pending acquire requests, FIFO
110
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
111
123
  // Slots reserved for a worker that's mid-reuse/respawn/fresh-spawn — pulled
112
124
  // out of `_free` (or not yet spawned) but not yet placed in `_busy`. Counted
113
125
  // toward `_poolSize` so a burst of concurrent acquireWorker calls can't
114
126
  // overshoot the configured pool size while spawns are still in flight.
115
127
  let _reserved = 0;
116
128
  let _draining = false;
129
+ let _enabled = true;
117
130
 
118
131
  function _occupied() {
119
132
  return _free.length + _busy.size + _reserved;
120
133
  }
121
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
+
122
172
  // ── Queue pump ──────────────────────────────────────────────────────────
123
173
  //
124
174
  // Fully synchronous per pass: each iteration either satisfies the head of
@@ -130,18 +180,48 @@ function _occupied() {
130
180
  // no-op re-pump; failure frees the reservation and DOES let another queued
131
181
  // request through).
132
182
  function _pump() {
183
+ if (_draining || !_enabled) return;
133
184
  while (_queue.length > 0) {
134
185
  const req = _queue[0];
135
186
 
136
- const matchIdx = _free.findIndex((w) =>
187
+ const compatible = (w) => (
137
188
  w.runtime === req.runtime
138
189
  && w.mcpServersHash === req.mcpServersHash
139
190
  && w.processContextHash === req.processContextHash
191
+ && w.processCwdKey === req.processCwdKey
140
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);
141
220
  if (matchIdx >= 0) {
142
221
  _queue.shift();
143
222
  const worker = _free.splice(matchIdx, 1)[0];
144
223
  _reserved++;
224
+ req.reserved = true;
145
225
  _reuseWorker(worker, req);
146
226
  continue;
147
227
  }
@@ -150,6 +230,7 @@ function _pump() {
150
230
  _queue.shift();
151
231
  const stale = _free.shift();
152
232
  _reserved++;
233
+ req.reserved = true;
153
234
  _respawnWorker(stale, req);
154
235
  continue;
155
236
  }
@@ -157,6 +238,7 @@ function _pump() {
157
238
  if (_occupied() < _poolSize) {
158
239
  _queue.shift();
159
240
  _reserved++;
241
+ req.reserved = true;
160
242
  _spawnFreshWorker(req);
161
243
  continue;
162
244
  }
@@ -168,40 +250,55 @@ function _pump() {
168
250
  }
169
251
 
170
252
  async function _reuseWorker(worker, req) {
253
+ req.worker = worker;
171
254
  try {
172
- 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
+ }
173
270
  _trace(`dispatch=${req.dispatchId} reuse: worker=${worker.id} sessionId=${worker.sessionId}`);
174
- _reserved--;
175
- if (_draining) {
176
- try { worker.close(); } catch { /* already torn down */ }
177
- 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));
178
275
  return;
179
276
  }
180
- req.resolve(worker);
277
+ _settleRequest(req, 'resolve', worker);
181
278
  } catch (err) {
182
- _reserved--;
279
+ _releaseReservation(req);
183
280
  // The worker died mid-newSession (or the daemon rejected the call) —
184
281
  // it's already unusable; don't return it to `_free`.
185
- try { worker.close(); } catch { /* already torn down */ }
186
- req.reject(err);
282
+ _closeWorker(worker);
283
+ _settleRequest(req, 'reject', err);
187
284
  _pump();
188
285
  }
189
286
  }
190
287
 
191
288
  async function _respawnWorker(stale, req) {
192
- try { stale.close(); } catch { /* already torn down */ }
289
+ _closeWorker(stale);
193
290
  try {
194
291
  const worker = await _bootWorker(req);
195
- _reserved--;
196
- if (_draining) {
197
- try { worker.close(); } catch { /* already torn down */ }
198
- 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));
199
296
  return;
200
297
  }
201
- req.resolve(worker);
298
+ _settleRequest(req, 'resolve', worker);
202
299
  } catch (err) {
203
- _reserved--;
204
- req.reject(err);
300
+ _releaseReservation(req);
301
+ _settleRequest(req, 'reject', err);
205
302
  _pump();
206
303
  }
207
304
  }
@@ -209,16 +306,16 @@ async function _respawnWorker(stale, req) {
209
306
  async function _spawnFreshWorker(req) {
210
307
  try {
211
308
  const worker = await _bootWorker(req);
212
- _reserved--;
213
- if (_draining) {
214
- try { worker.close(); } catch { /* already torn down */ }
215
- 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));
216
313
  return;
217
314
  }
218
- req.resolve(worker);
315
+ _settleRequest(req, 'resolve', worker);
219
316
  } catch (err) {
220
- _reserved--;
221
- req.reject(err);
317
+ _releaseReservation(req);
318
+ _settleRequest(req, 'reject', err);
222
319
  _pump();
223
320
  }
224
321
  }
@@ -231,6 +328,9 @@ async function _spawnFreshWorker(req) {
231
328
  // whichever pool collection it's currently sitting in, then re-pumps so a
232
329
  // queued request can spawn its replacement.
233
330
  async function _bootWorker(req) {
331
+ if (req.cancelled || _draining || !_enabled) {
332
+ throw _acquisitionUnavailableError(req);
333
+ }
234
334
  const worker = new Worker({
235
335
  runtime: req.runtime,
236
336
  id: `agent-pool-${_nextWorkerId++}`,
@@ -240,19 +340,33 @@ async function _bootWorker(req) {
240
340
  mcpServersHash: req.mcpServersHash,
241
341
  processEnv: req.processEnv,
242
342
  processContextHash: req.processContextHash,
343
+ workerOptions: req.workerOptions,
344
+ resumeSessionId: req.resumeSessionId,
243
345
  cwd: req.cwd,
346
+ processCwd: req.processCwd,
244
347
  internals: _internals,
245
348
  trace: _trace,
246
349
  });
350
+ worker.processCwdKey = req.processCwdKey;
351
+ req.worker = worker;
352
+ _starting.add(worker);
247
353
  const initPromise = worker._spawnAndInit();
354
+ const identityPromise = worker.proc?.pid
355
+ ? _internals.getProcessStartedAt(worker.proc.pid)
356
+ : Promise.resolve(0);
248
357
  worker.initPromise = initPromise;
249
358
  try {
250
- 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;
251
364
  } catch (err) {
252
- try { worker.close(); } catch { /* already torn down */ }
365
+ _closeWorker(worker);
253
366
  throw err;
254
367
  } finally {
255
368
  worker.initPromise = null;
369
+ _starting.delete(worker);
256
370
  }
257
371
  if (worker.proc) {
258
372
  worker.proc.once('exit', () => _onWorkerExit(worker));
@@ -315,11 +429,13 @@ function _clearLeaseContext(dispatchId) {
315
429
  }
316
430
 
317
431
  async function acquireWorker({
318
- runtime, dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
432
+ runtime, dispatchId, cwd, processCwd, model, effort, mcpServers, hermeticDirs,
433
+ processEnv, workerOptions, sessionId, sessionContext,
319
434
  } = {}) {
320
435
  if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
321
436
  if (_draining) throw new Error('agent-worker-pool: draining');
322
- 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)) {
323
439
  throw new Error(
324
440
  `agent-worker-pool.acquireWorker: dispatchId ${dispatchId} already has an active worker ` +
325
441
  '(1 worker : 1 active dispatch invariant — call release() before acquiring again)'
@@ -331,16 +447,39 @@ async function acquireWorker({
331
447
  const workerProcessEnv = processEnv && typeof processEnv === 'object'
332
448
  ? Object.freeze({ ...processEnv })
333
449
  : undefined;
334
- 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
+ });
335
466
  const leaseContext = sessionContext && typeof sessionContext === 'object'
336
467
  ? { ...sessionContext }
337
468
  : {};
338
469
  const req = {
339
470
  runtime: runtimeAdapter,
340
- dispatchId, cwd, model, effort, mcpServers, mcpServersHash,
341
- processEnv: workerProcessEnv, processContextHash,
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,
342
480
  };
343
481
  _leaseContexts.set(dispatchId, leaseContext);
482
+ _acquiring.set(dispatchId, req);
344
483
 
345
484
  let worker;
346
485
  try {
@@ -352,18 +491,23 @@ async function acquireWorker({
352
491
  });
353
492
 
354
493
  if (_draining) throw new Error('agent-worker-pool: draining');
494
+ if (req.cancelled || !_enabled) throw new Error('agent-worker-pool: acquisition cancelled');
355
495
  _busy.set(dispatchId, worker);
356
496
  await _applyHermeticDirs(worker, hermeticDirs);
357
497
  if (worker.killed) throw new Error('agent-worker-pool: worker died during acquisition');
358
- if (_draining) throw new Error('agent-worker-pool: draining');
498
+ if (req.cancelled || _draining || !_enabled) {
499
+ throw _acquisitionUnavailableError(req);
500
+ }
359
501
  } catch (err) {
360
502
  if (_busy.get(dispatchId) === worker) _busy.delete(dispatchId);
361
503
  _clearLeaseContext(dispatchId);
362
504
  if (worker) {
363
- try { worker.close(); } catch { /* already torn down */ }
505
+ _closeWorker(worker);
364
506
  _pump();
365
507
  }
366
508
  throw err;
509
+ } finally {
510
+ _acquiring.delete(dispatchId);
367
511
  }
368
512
 
369
513
  return {
@@ -376,6 +520,7 @@ async function acquireWorker({
376
520
  // snapshots of the underlying worker's current values (a getter, not a
377
521
  // captured copy), since sessionId can rotate under a reused worker.
378
522
  get pid() { return (worker.proc && worker.proc.pid) || null; },
523
+ get processStartedAt() { return worker.processStartedAt || 0; },
379
524
  get sessionId() { return worker.sessionId || null; },
380
525
  get currentModel() { return worker.currentModel || null; },
381
526
  stream: async (promptText, opts = {}) => {
@@ -393,9 +538,26 @@ async function acquireWorker({
393
538
  return worker.cancel();
394
539
  },
395
540
  release: () => release(dispatchId),
541
+ discard: () => discard(dispatchId),
542
+ abandon: () => abandon(dispatchId),
543
+ preserveDescendants: (pids) => worker.preserveDescendants(pids),
396
544
  };
397
545
  }
398
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
+
399
561
  // Return a leased worker to the free set — unless it died during the lease
400
562
  // (WORKER_DIED), in which case it's simply dropped (already evicted from
401
563
  // `_busy` by `_onWorkerExit` if the crash happened before release(), or
@@ -412,9 +574,18 @@ function release(dispatchId) {
412
574
  _pump();
413
575
  return true;
414
576
  }
415
- if (_draining) {
416
- try { worker.close(); } catch { /* already torn down */ }
417
- _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();
418
589
  return true;
419
590
  }
420
591
  _free.push(worker);
@@ -423,14 +594,55 @@ function release(dispatchId) {
423
594
  return true;
424
595
  }
425
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
+
426
637
  function beginDrain() {
427
638
  if (_draining) return;
428
639
  _draining = true;
429
640
  for (const req of _queue.splice(0)) {
430
- 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'));
431
643
  }
432
644
  for (const worker of _free.splice(0)) {
433
- try { worker.close(); } catch { /* already torn down */ }
645
+ _closeWorker(worker);
434
646
  }
435
647
  }
436
648
 
@@ -448,26 +660,29 @@ function getActiveLeaseIds() {
448
660
 
449
661
  function shutdown() {
450
662
  _draining = true;
451
- for (const req of _queue.splice(0)) {
452
- _clearLeaseContext(req.dispatchId);
453
- try { req.reject(new Error('agent-worker-pool: shutdown')); } catch { /* swallow */ }
454
- }
663
+ _enabled = false;
664
+ for (const dispatchId of [..._acquiring.keys()]) cancelAcquire(dispatchId);
455
665
  for (const worker of _free.splice(0)) {
456
- try { worker.close(); } catch { /* swallow */ }
666
+ _closeWorker(worker);
457
667
  }
458
668
  for (const [, worker] of _busy) {
459
- try { worker.close(); } catch { /* swallow */ }
669
+ _closeWorker(worker);
460
670
  }
671
+ for (const worker of _starting) _closeWorker(worker);
461
672
  _busy.clear();
462
673
  for (const dispatchId of [..._leaseContexts.keys()]) _clearLeaseContext(dispatchId);
463
- _reserved = 0;
464
674
  }
465
675
 
466
676
  module.exports = {
467
677
  acquireWorker,
678
+ cancelAcquire,
468
679
  release,
680
+ discard,
681
+ abandon,
469
682
  setPoolSize,
470
683
  getPoolSize,
684
+ setEnabled,
685
+ isEnabled,
471
686
  beginDrain,
472
687
  isDraining,
473
688
  isDrained,
@@ -480,4 +695,6 @@ module.exports = {
480
695
  _busy,
481
696
  _queue,
482
697
  _leaseContexts,
698
+ _acquiring,
699
+ _starting,
483
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
 
@@ -207,7 +208,13 @@ async function getSession({ runtime, tabId, model, effort, mcpServers, systemPro
207
208
  // and create a fresh one. Saves the ~2.1 s initialize handshake.
208
209
  // Pass model/effort so the NEW session picks up any override the
209
210
  // caller has rotated in (Bug B / issue #2479).
210
- 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
+ }
211
218
  worker.lastUsedAt = _internals.now();
212
219
  lifecycle = 'new-session';
213
220
  } else {
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
  }