@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.
@@ -27,6 +27,54 @@ const {
27
27
  encodePooledOutput,
28
28
  } = require('./runtimes');
29
29
 
30
+ const CANCEL_GRACE_MS = 1500;
31
+ const DESCENDANT_INITIAL_SNAPSHOT_MS = 3000;
32
+ const DESCENDANT_SNAPSHOT_INTERVAL_MS = 30000;
33
+ const TOOL_SNAPSHOT_DELAYS_MS = [250, 1000];
34
+
35
+ const _internals = {
36
+ cancelGraceMs: CANCEL_GRACE_MS,
37
+ killVerifyDelayMs: 50,
38
+ listAllProcesses() {
39
+ return require('./shared').listAllProcessesAsync();
40
+ },
41
+ listProcessDescendants(pid, processes) {
42
+ return require('./shared').listProcessDescendants(pid, processes);
43
+ },
44
+ findProcessesWithCwdInside(cwd) {
45
+ return require('./shared').findProcessesWithCwdInsideAsync(cwd);
46
+ },
47
+ killByPidsImmediate(pids) {
48
+ return require('./shared').killByPidsImmediate(pids);
49
+ },
50
+ computeReapPlan(pids, agentId, opts) {
51
+ return require('./keep-process-sweep').computeReapPlan(pids, agentId, opts);
52
+ },
53
+ };
54
+
55
+ function dispatchOwnedDescendants(rootPid, baselinePids, processes) {
56
+ const baseline = baselinePids instanceof Map || baselinePids instanceof Set
57
+ ? baselinePids
58
+ : new Set(baselinePids || []);
59
+ const descendants = _internals.listProcessDescendants(rootPid, processes);
60
+ const processByPid = new Map((processes || []).map((entry) => [Number(entry.pid), entry]));
61
+ return descendants.filter((pid) => {
62
+ let current = Number(pid);
63
+ const seen = new Set();
64
+ while (current && current !== Number(rootPid) && !seen.has(current)) {
65
+ if (baseline instanceof Map && baseline.has(current)) {
66
+ const startedAt = Number(processByPid.get(current)?.startedAt) || 0;
67
+ if (startedAt > 0 && startedAt === baseline.get(current)) return false;
68
+ } else if (baseline.has(current)) {
69
+ return false;
70
+ }
71
+ seen.add(current);
72
+ current = Number(processByPid.get(current)?.ppid);
73
+ }
74
+ return true;
75
+ });
76
+ }
77
+
30
78
  class PooledAgentProcess extends EventEmitter {
31
79
  /**
32
80
  * @param {object} opts
@@ -34,21 +82,30 @@ class PooledAgentProcess extends EventEmitter {
34
82
  * @param {object} opts.runtime - resolved runtime adapter
35
83
  * @param {string} opts.dispatchId
36
84
  * @param {string} opts.cwd
85
+ * @param {string} [opts.processCwd] - stable checkout used by the worker process
37
86
  * @param {string} [opts.model]
38
87
  * @param {string} [opts.effort]
39
88
  * @param {Array} [opts.mcpServers]
40
89
  * @param {Array<string>} [opts.hermeticDirs]
41
90
  * @param {object} [opts.processEnv] - worker-process environment
91
+ * @param {object} [opts.workerOptions] - adapter-owned process-scoped options
92
+ * @param {string} [opts.sessionId] - existing ACP session to load
42
93
  * @param {object} [opts.sessionContext] - trusted values scoped to this lease
94
+ * @param {string} [opts.agentId] - owner used for keep-processes validation
95
+ * @param {boolean} [opts.reapDescendants] - reap lease-created child processes
96
+ * @param {boolean} [opts.keepProcessesSkipWorkdirCheck]
43
97
  * @param {string} [opts.systemPromptText] - trusted system instructions
44
98
  * @param {string} opts.promptText - task/user prompt only
45
99
  */
46
100
  constructor({
47
- pool, runtime, dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
48
- processEnv, sessionContext, systemPromptText, promptText,
101
+ pool, runtime, dispatchId, cwd, processCwd, model, effort, mcpServers, hermeticDirs,
102
+ processEnv, workerOptions, sessionId, sessionContext,
103
+ agentId, reapDescendants, keepProcessesSkipWorkdirCheck,
104
+ systemPromptText, promptText,
49
105
  }) {
50
106
  super();
51
107
  this.pid = null;
108
+ this._expectedWorkerStartedAt = 0;
52
109
  this.killed = false;
53
110
  this.exitCode = null;
54
111
  this.stdout = new PassThrough();
@@ -57,31 +114,68 @@ class PooledAgentProcess extends EventEmitter {
57
114
  this._runtime = runtime || resolveRuntimeByCapability('acpWorkerPool');
58
115
  this._dispatchId = dispatchId;
59
116
  this._handle = null;
117
+ this._handleDiscarded = false;
118
+ this._killExitCode = null;
119
+ this._cancelTimer = null;
120
+ this._cleanupPromise = null;
121
+ this._cleanupApplied = false;
60
122
  this._closed = false;
123
+ this._closePromise = new Promise((resolve) => { this._resolveClose = resolve; });
61
124
  this._startedAt = Date.now();
62
125
  this._runtimeOutputError = null;
126
+ this._agentId = agentId || null;
127
+ this._sessionCwd = cwd || null;
128
+ this._reapDescendants = reapDescendants === true;
129
+ this._keepProcessesSkipWorkdirCheck = keepProcessesSkipWorkdirCheck === true;
130
+ this._baselineDescendants = null;
131
+ this._baselineCwdHolders = new Map();
132
+ this._workerStartedAt = 0;
133
+ this._trackedLeaseDescendants = new Map();
134
+ this._initialDescendantTimer = null;
135
+ this._descendantTimer = null;
136
+ this._descendantCaptureQueued = false;
137
+ this._descendantSnapshotInFlight = null;
138
+ this._descendantCaptureQueued = false;
139
+ this._descendantTrackingFailed = false;
140
+ this._toolSnapshotTimers = new Set();
63
141
 
64
142
  // Fire-and-forget, mirroring child_process.spawn()'s async nature — the
65
143
  // facade is usable (event listeners can be attached) immediately, before
66
144
  // the underlying ACP worker has actually been acquired.
67
145
  this._start({
68
- cwd, model, effort, mcpServers, hermeticDirs,
69
- processEnv, sessionContext, systemPromptText, promptText,
146
+ cwd, processCwd, model, effort, mcpServers, hermeticDirs,
147
+ processEnv, workerOptions, sessionId, sessionContext,
148
+ systemPromptText, promptText,
70
149
  });
71
150
  }
72
151
 
73
152
  async _start({
74
- cwd, model, effort, mcpServers, hermeticDirs,
75
- processEnv, sessionContext, systemPromptText, promptText,
153
+ cwd, processCwd, model, effort, mcpServers, hermeticDirs,
154
+ processEnv, workerOptions, sessionId, sessionContext,
155
+ systemPromptText, promptText,
76
156
  }) {
77
157
  let handle;
78
158
  try {
79
- handle = await this._pool.acquireWorker({
159
+ const acquireOptions = {
80
160
  runtime: this._runtime,
81
161
  dispatchId: this._dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
82
162
  processEnv, sessionContext,
83
- });
163
+ };
164
+ if (processCwd) acquireOptions.processCwd = processCwd;
165
+ if (workerOptions !== undefined) acquireOptions.workerOptions = workerOptions;
166
+ if (sessionId) acquireOptions.sessionId = sessionId;
167
+ handle = await this._pool.acquireWorker(acquireOptions);
84
168
  } catch (err) {
169
+ if (this.killed) return;
170
+ if (err && err.acpMethod === 'session/load') {
171
+ const missing = /(?:resource|session|conversation).*(?:not found|missing)|no conversation/i
172
+ .test(String(err.message || ''));
173
+ try {
174
+ this.stderr.write(`${missing ? 'No conversation found: ' : ''}${err.message}\n`);
175
+ } catch { /* best-effort */ }
176
+ this._finish(1);
177
+ return;
178
+ }
85
179
  // Mirrors child_process's 'error' event (spawn failed) — engine.js's
86
180
  // existing `proc.on('error', ...)` handler already does the right
87
181
  // thing (completeDispatch ERROR + cleanup) unmodified.
@@ -93,6 +187,7 @@ class PooledAgentProcess extends EventEmitter {
93
187
  // kill() landed while acquisition was still in flight. No OS process
94
188
  // was ever visible to anything downstream — just return the worker.
95
189
  try { handle.release(); } catch { /* best-effort */ }
190
+ this._finish(this._killExitCode ?? 143);
96
191
  return;
97
192
  }
98
193
 
@@ -101,10 +196,35 @@ class PooledAgentProcess extends EventEmitter {
101
196
  if (handle.currentModel) {
102
197
  this._writeRuntimeEvent({ kind: 'model', model: handle.currentModel });
103
198
  }
199
+ this._expectedWorkerStartedAt = Number(handle.processStartedAt) || 0;
104
200
  // Lets engine.js write the PID-file equivalent (spawn-agent.js normally
105
201
  // does this itself, from inside the child process) as soon as a real OS
106
202
  // PID is known — see engine.js's pooled branch in spawnAgent().
107
203
  this.emit('acquired');
204
+ if (this.killed) {
205
+ this._clearCancelTimer();
206
+ this._abandonHandle(handle);
207
+ this._handle = null;
208
+ this._finish(this._killExitCode ?? 143);
209
+ return;
210
+ }
211
+ const descendantTrackingReady = await this._snapshotDescendants();
212
+ if (this.killed) {
213
+ this._clearCancelTimer();
214
+ this._stopDescendantTracking();
215
+ if (descendantTrackingReady) this._discardHandle(handle);
216
+ else this._abandonHandle(handle);
217
+ this._handle = null;
218
+ this._finish(this._killExitCode ?? 143);
219
+ return;
220
+ }
221
+ if (!descendantTrackingReady) {
222
+ this._abandonHandle(handle);
223
+ this._handle = null;
224
+ try { this.stderr.write('Pooled worker process cleanup could not be initialized\n'); } catch { /* best-effort */ }
225
+ this._finish(1);
226
+ return;
227
+ }
108
228
 
109
229
  let sawError = false;
110
230
  try {
@@ -126,15 +246,28 @@ class PooledAgentProcess extends EventEmitter {
126
246
  sawError = true;
127
247
  try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
128
248
  },
249
+ onToolUse: () => this._captureAroundToolCall(),
250
+ onToolUpdate: () => { void this._captureLeaseDescendants(); },
129
251
  });
130
252
  } catch (err) {
131
253
  sawError = true;
132
254
  try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
133
255
  } finally {
134
- try { handle.release(); } catch { /* best-effort */ }
256
+ this._clearCancelTimer();
257
+ const cleanup = await this._getLeaseCleanup();
258
+ try {
259
+ if (!cleanup.safe) {
260
+ sawError = true;
261
+ try { this.stderr.write('Pooled worker process cleanup could not be completed safely\n'); } catch { /* best-effort */ }
262
+ }
263
+ this._applyCleanupToHandle(handle, cleanup);
264
+ if (cleanup.abandon) this._abandonHandle(handle);
265
+ else if (this.killed || cleanup.discard) this._discardHandle(handle);
266
+ else if (!this._handleDiscarded) handle.release();
267
+ } catch { /* best-effort */ }
135
268
  this._handle = null;
136
269
  }
137
- this._finish(sawError || this._runtimeOutputError ? 1 : 0);
270
+ this._finish(this._killExitCode ?? (sawError || this._runtimeOutputError ? 1 : 0));
138
271
  }
139
272
 
140
273
  _writeRuntimeEvent(event) {
@@ -152,29 +285,361 @@ class PooledAgentProcess extends EventEmitter {
152
285
  this.exitCode = code;
153
286
  try { this.stdout.end(); } catch { /* best-effort */ }
154
287
  try { this.stderr.end(); } catch { /* best-effort */ }
288
+ this._resolveClose(code);
155
289
  this.emit('close', code);
156
290
  }
157
291
 
158
- // child_process.kill()-shaped primitive. Cancels the in-flight ACP turn
159
- // (if any) and releases the worker back to the pool rather than killing
160
- // the shared OS process — killing it would collaterally affect whatever
161
- // OTHER dispatch reuses that worker next (never a concurrent sibling, per
162
- // the pool's 1-worker:1-active-dispatch invariant, but still a real
163
- // process other queued dispatches may be waiting to reuse).
292
+ waitForClose() {
293
+ return this._closePromise;
294
+ }
295
+
296
+ _discardHandle(handle) {
297
+ if (this._handleDiscarded) return;
298
+ this._handleDiscarded = true;
299
+ try {
300
+ if (typeof handle.discard === 'function') handle.discard();
301
+ else handle.release();
302
+ } catch { /* best-effort */ }
303
+ }
304
+
305
+ _abandonHandle(handle) {
306
+ if (this._handleDiscarded) return;
307
+ this._handleDiscarded = true;
308
+ try {
309
+ if (typeof handle.abandon === 'function') handle.abandon();
310
+ else handle.release();
311
+ } catch { /* best-effort */ }
312
+ }
313
+
314
+ _clearCancelTimer() {
315
+ if (this._cancelTimer) clearTimeout(this._cancelTimer);
316
+ this._cancelTimer = null;
317
+ }
318
+
319
+ _getLeaseCleanup() {
320
+ if (!this._cleanupPromise) this._cleanupPromise = this._reapLeaseDescendants();
321
+ return this._cleanupPromise;
322
+ }
323
+
324
+ _applyCleanupToHandle(handle, cleanup) {
325
+ if (this._cleanupApplied) return;
326
+ this._cleanupApplied = true;
327
+ if (cleanup.preservedPids.length > 0 && typeof handle.preserveDescendants === 'function') {
328
+ handle.preserveDescendants(cleanup.preservedPids);
329
+ }
330
+ }
331
+
332
+ _scheduleForcedCancellation(handle) {
333
+ const graceMs = Math.max(0, Number(_internals.cancelGraceMs) || 0);
334
+ this._cancelTimer = setTimeout(() => {
335
+ void (async () => {
336
+ try {
337
+ const cleanup = await this._getLeaseCleanup();
338
+ this._applyCleanupToHandle(handle, cleanup);
339
+ if (cleanup.abandon) this._abandonHandle(handle);
340
+ else this._discardHandle(handle);
341
+ } catch {
342
+ this._abandonHandle(handle);
343
+ }
344
+ })();
345
+ }, graceMs);
346
+ }
347
+
348
+ async _snapshotDescendants() {
349
+ if (!this._reapDescendants) return true;
350
+ if (!this.pid) return false;
351
+ try {
352
+ const holders = this._sessionCwd
353
+ ? await _internals.findProcessesWithCwdInside(this._sessionCwd)
354
+ : [];
355
+ const processes = await _internals.listAllProcesses();
356
+ if (!Array.isArray(processes) || processes.length === 0) throw new Error('process table unavailable');
357
+ const processByPid = new Map(processes.map((entry) => [Number(entry.pid), entry]));
358
+ this._workerStartedAt = Number(processByPid.get(this.pid)?.startedAt) || 0;
359
+ if (this._expectedWorkerStartedAt <= 0
360
+ || this._workerStartedAt !== this._expectedWorkerStartedAt) {
361
+ throw new Error('worker process identity changed before lease start');
362
+ }
363
+ this._baselineDescendants = new Map();
364
+ for (const pid of _internals.listProcessDescendants(this.pid, processes)) {
365
+ const startedAt = Number(processByPid.get(Number(pid))?.startedAt) || 0;
366
+ if (startedAt <= 0) throw new Error('process identity unavailable');
367
+ this._baselineDescendants.set(Number(pid), startedAt);
368
+ }
369
+ this._baselineCwdHolders = new Map();
370
+ for (const holder of holders) {
371
+ const pid = Number(holder.pid);
372
+ const current = processByPid.get(pid);
373
+ if (!current) continue;
374
+ const holderStartedAt = Number(holder.startedAt) || 0;
375
+ const currentStartedAt = Number(current.startedAt) || 0;
376
+ if (holderStartedAt <= 0 || currentStartedAt <= 0) {
377
+ throw new Error('cwd-holder process identity unavailable');
378
+ }
379
+ if (holderStartedAt === currentStartedAt) {
380
+ this._baselineCwdHolders.set(pid, currentStartedAt);
381
+ }
382
+ }
383
+ this._initialDescendantTimer = setTimeout(
384
+ () => { void this._captureLeaseDescendants(); },
385
+ DESCENDANT_INITIAL_SNAPSHOT_MS
386
+ );
387
+ this._descendantTimer = setInterval(
388
+ () => { void this._captureLeaseDescendants(); },
389
+ DESCENDANT_SNAPSHOT_INTERVAL_MS
390
+ );
391
+ if (this._initialDescendantTimer.unref) this._initialDescendantTimer.unref();
392
+ if (this._descendantTimer.unref) this._descendantTimer.unref();
393
+ return true;
394
+ } catch {
395
+ this._baselineDescendants = null;
396
+ this._descendantTrackingFailed = true;
397
+ return false;
398
+ }
399
+ }
400
+
401
+ async _captureLeaseDescendants() {
402
+ if (!(this._baselineDescendants instanceof Map) || !this.pid) return;
403
+ if (this._descendantSnapshotInFlight) {
404
+ this._descendantCaptureQueued = true;
405
+ try { await this._descendantSnapshotInFlight; } catch { /* owner records the failure */ }
406
+ return;
407
+ }
408
+ const capture = (async () => {
409
+ do {
410
+ this._descendantCaptureQueued = false;
411
+ const processes = await _internals.listAllProcesses();
412
+ if (!Array.isArray(processes) || processes.length === 0) throw new Error('process table unavailable');
413
+ const processByPid = new Map(processes.map((entry) => [Number(entry.pid), entry]));
414
+ if (Number(processByPid.get(this.pid)?.startedAt) !== this._workerStartedAt) {
415
+ throw new Error('worker process identity changed');
416
+ }
417
+ for (const pid of dispatchOwnedDescendants(this.pid, this._baselineDescendants, processes)) {
418
+ const startedAt = Number(processByPid.get(Number(pid))?.startedAt) || 0;
419
+ if (startedAt <= 0) throw new Error('process identity unavailable');
420
+ this._trackedLeaseDescendants.set(Number(pid), startedAt);
421
+ }
422
+ } while (this._descendantCaptureQueued);
423
+ })();
424
+ this._descendantSnapshotInFlight = capture;
425
+ try {
426
+ await capture;
427
+ } catch {
428
+ this._descendantTrackingFailed = true;
429
+ } finally {
430
+ if (this._descendantSnapshotInFlight === capture) this._descendantSnapshotInFlight = null;
431
+ }
432
+ }
433
+
434
+ _stopDescendantTracking() {
435
+ if (this._initialDescendantTimer) clearTimeout(this._initialDescendantTimer);
436
+ if (this._descendantTimer) clearInterval(this._descendantTimer);
437
+ this._initialDescendantTimer = null;
438
+ this._descendantTimer = null;
439
+ for (const timer of this._toolSnapshotTimers) clearTimeout(timer);
440
+ this._toolSnapshotTimers.clear();
441
+ }
442
+
443
+ _captureAroundToolCall() {
444
+ void this._captureLeaseDescendants();
445
+ for (const delayMs of TOOL_SNAPSHOT_DELAYS_MS) {
446
+ const timer = setTimeout(() => {
447
+ this._toolSnapshotTimers.delete(timer);
448
+ void this._captureLeaseDescendants();
449
+ }, delayMs);
450
+ this._toolSnapshotTimers.add(timer);
451
+ }
452
+ }
453
+
454
+ async _killAndVerify(identities) {
455
+ if (!(identities instanceof Map) || identities.size === 0) return true;
456
+ const before = await _internals.listAllProcesses();
457
+ if (!Array.isArray(before) || before.length === 0) {
458
+ throw new Error('process table unavailable before identity-bound kill');
459
+ }
460
+ const beforeByPid = new Map(before.map((entry) => [Number(entry.pid), entry]));
461
+ const pids = [];
462
+ for (const [pid, startedAt] of identities) {
463
+ const current = beforeByPid.get(pid);
464
+ if (!current) continue;
465
+ const currentStartedAt = Number(current.startedAt) || 0;
466
+ if (currentStartedAt <= 0) return false;
467
+ if (currentStartedAt === startedAt) pids.push(pid);
468
+ }
469
+ if (pids.length === 0) return true;
470
+ _internals.killByPidsImmediate(pids);
471
+ const delayMs = Math.max(0, Number(_internals.killVerifyDelayMs) || 0);
472
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
473
+ const processes = await _internals.listAllProcesses();
474
+ if (!Array.isArray(processes) || processes.length === 0) {
475
+ throw new Error('process table unavailable during kill verification');
476
+ }
477
+ const processByPid = new Map(processes.map((entry) => [Number(entry.pid), entry]));
478
+ return [...identities].every(([pid, startedAt]) => {
479
+ const current = processByPid.get(pid);
480
+ if (!current) return true;
481
+ const currentStartedAt = Number(current.startedAt) || 0;
482
+ return currentStartedAt > 0 && currentStartedAt !== startedAt;
483
+ });
484
+ }
485
+
486
+ async _reapLeaseDescendants() {
487
+ if (!this._reapDescendants || !this.pid) {
488
+ return { safe: true, discard: false, preservedPids: [] };
489
+ }
490
+ this._stopDescendantTracking();
491
+ const reapOpts = this._keepProcessesSkipWorkdirCheck ? { requireGitWorkdir: false } : {};
492
+ const failClosed = () => {
493
+ try {
494
+ const anchorState = _internals.computeReapPlan([], this._agentId, reapOpts);
495
+ return {
496
+ safe: false,
497
+ discard: true,
498
+ preservedPids: anchorState.record?.pids || [],
499
+ abandon: true,
500
+ };
501
+ } catch {
502
+ return { safe: false, discard: true, preservedPids: [], abandon: true };
503
+ }
504
+ };
505
+ try {
506
+ if (this._descendantSnapshotInFlight) await this._descendantSnapshotInFlight;
507
+ if (!(this._baselineDescendants instanceof Map)) {
508
+ return { ...failClosed(), abandon: true };
509
+ }
510
+ const cwdHolders = this._sessionCwd
511
+ ? await _internals.findProcessesWithCwdInside(this._sessionCwd)
512
+ : [];
513
+ const processes = await _internals.listAllProcesses();
514
+ if (!Array.isArray(processes) || processes.length === 0) {
515
+ return { ...failClosed(), abandon: true };
516
+ }
517
+ const processByPid = new Map(processes.map((entry) => [Number(entry.pid), entry]));
518
+ const rootChanged = Number(processByPid.get(this.pid)?.startedAt) !== this._workerStartedAt;
519
+ if (this._descendantTrackingFailed) {
520
+ return rootChanged ? { ...failClosed(), abandon: true } : failClosed();
521
+ }
522
+ const candidates = new Set();
523
+ const addKnownTree = (pid, expectedStartedAt) => {
524
+ const value = Number(pid);
525
+ const current = processByPid.get(value);
526
+ if (!current) return true;
527
+ const startedAt = Number(current.startedAt) || 0;
528
+ if (startedAt <= 0) return false;
529
+ if (startedAt !== expectedStartedAt) return true;
530
+ candidates.add(value);
531
+ for (const childPid of _internals.listProcessDescendants(value, processes)) {
532
+ const child = processByPid.get(Number(childPid));
533
+ const childStartedAt = Number(child?.startedAt) || 0;
534
+ if (childStartedAt <= 0) return false;
535
+ candidates.add(Number(childPid));
536
+ }
537
+ return true;
538
+ };
539
+ for (const [pid, startedAt] of this._trackedLeaseDescendants) {
540
+ if (!addKnownTree(pid, startedAt)) return failClosed();
541
+ }
542
+ for (const holder of cwdHolders) {
543
+ const pid = Number(holder.pid);
544
+ const current = processByPid.get(pid);
545
+ if (!current) continue;
546
+ const holderStartedAt = Number(holder.startedAt) || 0;
547
+ const currentStartedAt = Number(current.startedAt) || 0;
548
+ if (holderStartedAt <= 0 || currentStartedAt <= 0) return failClosed();
549
+ if (holderStartedAt !== currentStartedAt) continue;
550
+ if (this._baselineCwdHolders.get(pid) === currentStartedAt) continue;
551
+ if (!addKnownTree(pid, currentStartedAt)) return failClosed();
552
+ }
553
+ if (rootChanged) {
554
+ for (const [pid, startedAt] of this._baselineDescendants) {
555
+ if (!addKnownTree(pid, startedAt)) return failClosed();
556
+ }
557
+ } else {
558
+ for (const pid of dispatchOwnedDescendants(this.pid, this._baselineDescendants, processes)) {
559
+ const startedAt = Number(processByPid.get(Number(pid))?.startedAt) || 0;
560
+ if (startedAt <= 0) return failClosed();
561
+ candidates.add(Number(pid));
562
+ }
563
+ }
564
+ if (candidates.size === 0) {
565
+ return rootChanged
566
+ ? { ...failClosed(), abandon: true }
567
+ : { safe: true, discard: false, preservedPids: [] };
568
+ }
569
+ const plan = _internals.computeReapPlan([...candidates], this._agentId, reapOpts);
570
+ const candidateIdentities = new Map(plan.toKill.map((pid) => [
571
+ Number(pid),
572
+ Number(processByPid.get(Number(pid))?.startedAt) || 0,
573
+ ]));
574
+ if (await this._killAndVerify(candidateIdentities)) {
575
+ if (plan.kept.length > 0 && !rootChanged) {
576
+ const kept = new Set(plan.kept.map(Number));
577
+ const beforeDiscard = await _internals.listAllProcesses();
578
+ if (!Array.isArray(beforeDiscard) || beforeDiscard.length === 0) {
579
+ return { safe: false, discard: true, preservedPids: plan.kept, abandon: true };
580
+ }
581
+ const discardByPid = new Map(beforeDiscard.map((entry) => [Number(entry.pid), entry]));
582
+ if (Number(discardByPid.get(this.pid)?.startedAt) !== this._workerStartedAt) {
583
+ return { safe: false, discard: true, preservedPids: plan.kept, abandon: true };
584
+ }
585
+ const nonAnchoredDescendants = new Map();
586
+ for (const pid of _internals.listProcessDescendants(this.pid, beforeDiscard)) {
587
+ const value = Number(pid);
588
+ if (kept.has(value)) continue;
589
+ const startedAt = Number(discardByPid.get(value)?.startedAt) || 0;
590
+ if (startedAt <= 0) {
591
+ return { safe: false, discard: true, preservedPids: plan.kept, abandon: true };
592
+ }
593
+ nonAnchoredDescendants.set(value, startedAt);
594
+ }
595
+ if (!await this._killAndVerify(nonAnchoredDescendants)) {
596
+ return { safe: false, discard: true, preservedPids: plan.kept, abandon: true };
597
+ }
598
+ }
599
+ return {
600
+ safe: !rootChanged,
601
+ discard: rootChanged || plan.kept.length > 0,
602
+ preservedPids: plan.kept,
603
+ abandon: rootChanged,
604
+ };
605
+ }
606
+ // Discarding the whole worker would also kill explicitly anchored
607
+ // keep_processes children, so preserve the worker when such anchors exist.
608
+ return {
609
+ safe: false,
610
+ discard: true,
611
+ preservedPids: plan.kept,
612
+ abandon: true,
613
+ };
614
+ } catch {
615
+ return { ...failClosed(), abandon: true };
616
+ }
617
+ }
618
+
619
+ // child_process.kill()-shaped primitive. Cancels the in-flight ACP turn and
620
+ // releases its lease; the pool evicts the worker if that turn is not settled,
621
+ // preventing a cancelled response from leaking into the next dispatch.
164
622
  // Full timeout/steering-kill cancel-vs-kill wiring in engine/timeout.js is
165
623
  // P-6e2a4c15 — this method is the primitive that work item calls into.
166
624
  kill() {
167
625
  if (this.killed) return true;
168
626
  this.killed = true;
627
+ this._killExitCode = 143;
169
628
  if (this._handle) {
170
629
  try { this._handle.cancel(); } catch { /* best-effort */ }
171
- try { this._handle.release(); } catch { /* best-effort */ }
172
- this._finish(this.exitCode == null ? 143 : this.exitCode);
630
+ this._scheduleForcedCancellation(this._handle);
631
+ } else if (this._pool && typeof this._pool.cancelAcquire === 'function') {
632
+ try { this._pool.cancelAcquire(this._dispatchId); } catch { /* best-effort */ }
633
+ this._finish(this._killExitCode);
634
+ } else {
635
+ this._finish(this._killExitCode);
173
636
  }
174
- // If acquisition is still in flight, `_start` observes `this.killed` and
175
- // releases the worker itself as soon as it lands — nothing more to do.
176
637
  return true;
177
638
  }
178
639
  }
179
640
 
180
- module.exports = { PooledAgentProcess };
641
+ module.exports = {
642
+ PooledAgentProcess,
643
+ dispatchOwnedDescendants, // exported for testing
644
+ _internals, // exported for testing
645
+ };
@@ -184,19 +184,17 @@ function buildCreatePrFollowups({ project, branch, contextOnly = false, checkout
184
184
 
185
185
  let message;
186
186
  if (checkoutMode === 'worktree') {
187
- // Worktree-mode projects: the server moves the live-checkout changes into an
188
- // isolated worktree and restores the live checkout clean, so the commit /
189
- // push / PR all happen OFF the operator's tree. CC never runs git in the
190
- // live checkout — that is what kept leaking commits onto `main`.
187
+ // Worktree-mode projects: copy pre-existing operator changes into an
188
+ // isolated worktree without modifying their checkout.
191
189
  message =
192
- `Create a PR from the local changes you just made in the ${proj} project. ` +
193
- `This project uses isolated worktrees, so do NOT commit, branch, or push in its live checkout. ` +
194
- `Steps: (1) call POST /api/pr-action/prepare-create-pr-worktree with {"project":"${proj}"} — the server moves your uncommitted changes into a fresh isolated worktree, restores the live checkout clean, and returns {worktreePath, branch, baseBranch}; ` +
190
+ `Create a PR from the pre-existing operator changes in the ${proj} project. ` +
191
+ `This project uses isolated worktrees, so do NOT edit, commit, branch, clean, reset, or push in its operator checkout. ` +
192
+ `Steps: (1) call POST /api/pr-action/prepare-create-pr-worktree with {"project":"${proj}"} — the server copies the uncommitted changes into a fresh isolated worktree, leaves the operator checkout unchanged, and returns {worktreePath, branch, baseBranch}; ` +
195
193
  `(2) run all git from inside worktreePath (use \`git -C <worktreePath> ...\`): stage and commit the changes on the returned branch with a clear conventional-commit message; ` +
196
194
  `(3) push that branch; (4) open a PR against baseBranch using the right CLI for the repo host (gh for GitHub, az repos for ADO); ` +
197
195
  `(5) link it by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}; ` +
198
196
  `(6) finally call POST /api/pr-action/cleanup-create-pr-worktree with {"project":"${proj}","worktreePath":"<worktreePath>"} to remove the worktree. ` +
199
- `Never git-commit/-push in the live checkout. Then show me the PR URL.`;
197
+ `Do not clean up the operator's original changes; report that they remain untouched. Then show me the PR URL.`;
200
198
  } else {
201
199
  // Live-checkout projects: operate in place, but ALWAYS build the PR on a
202
200
  // fresh branch off the up-to-date origin/<mainBranch> tip — never off