@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
@@ -15,77 +15,167 @@
15
15
  * completely unmodified whether `proc` is a real child_process or a pooled
16
16
  * ACP lease.
17
17
  *
18
- * Output translation (parity, not full fidelity): ACP `session/update` text
19
- * chunks are re-encoded as the SAME Copilot JSONL event shape
20
- * `engine/runtimes/copilot.js#parseOutput` already parses from a real
21
- * `copilot` CLI stdout stream — `{"type":"assistant.message_delta","data":
22
- * {"deltaContent":"..."}}` per chunk, plus a terminal `{"type":"result",
23
- * "sessionId":...,"usage":{...}}` line once the turn completes. This is the
24
- * minimum subset `parseOutput` needs to recover an equivalent `{text,
25
- * sessionId, usage}` to the cold-spawn path (deltas alone populate its
26
- * `pendingDeltaContent` accumulator; no `assistant.message`/`session.
27
- * tools_updated`/`tool.execution_*` events are required for that recovery).
28
- * Tool-call and model-announcement events are intentionally NOT synthesized
29
- * — a deliberate, documented scope limit, not an oversight: the live-output
30
- * log still gets the real text in real time either way, and `usage`'s
31
- * cost/token fields are already null-for-copilot on the cold path (see
32
- * `runtimes/copilot.js#parseOutput`), so nothing measurable is lost.
18
+ * Output translation (parity, not full fidelity) is adapter-owned through
19
+ * `encodePooledOutput()`. The facade only forwards semantic chunk/result
20
+ * events, so a harness can change its JSONL schema without changing this file.
33
21
  */
34
22
 
35
23
  const { EventEmitter } = require('events');
36
24
  const { PassThrough } = require('stream');
25
+ const {
26
+ resolveRuntimeByCapability,
27
+ encodePooledOutput,
28
+ } = require('./runtimes');
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
+ }
37
77
 
38
78
  class PooledAgentProcess extends EventEmitter {
39
79
  /**
40
80
  * @param {object} opts
41
81
  * @param {object} opts.pool - engine/agent-worker-pool.js module (or a test double)
82
+ * @param {object} opts.runtime - resolved runtime adapter
42
83
  * @param {string} opts.dispatchId
43
84
  * @param {string} opts.cwd
85
+ * @param {string} [opts.processCwd] - stable checkout used by the worker process
44
86
  * @param {string} [opts.model]
45
87
  * @param {string} [opts.effort]
46
88
  * @param {Array} [opts.mcpServers]
47
89
  * @param {Array<string>} [opts.hermeticDirs]
48
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
49
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]
50
97
  * @param {string} [opts.systemPromptText] - trusted system instructions
51
98
  * @param {string} opts.promptText - task/user prompt only
52
99
  */
53
100
  constructor({
54
- pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
55
- 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,
56
105
  }) {
57
106
  super();
58
107
  this.pid = null;
108
+ this._expectedWorkerStartedAt = 0;
59
109
  this.killed = false;
60
110
  this.exitCode = null;
61
111
  this.stdout = new PassThrough();
62
112
  this.stderr = new PassThrough();
63
113
  this._pool = pool;
114
+ this._runtime = runtime || resolveRuntimeByCapability('acpWorkerPool');
64
115
  this._dispatchId = dispatchId;
65
116
  this._handle = null;
117
+ this._handleDiscarded = false;
118
+ this._killExitCode = null;
119
+ this._cancelTimer = null;
120
+ this._cleanupPromise = null;
121
+ this._cleanupApplied = false;
66
122
  this._closed = false;
123
+ this._closePromise = new Promise((resolve) => { this._resolveClose = resolve; });
67
124
  this._startedAt = Date.now();
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();
68
141
 
69
142
  // Fire-and-forget, mirroring child_process.spawn()'s async nature — the
70
143
  // facade is usable (event listeners can be attached) immediately, before
71
144
  // the underlying ACP worker has actually been acquired.
72
145
  this._start({
73
- cwd, model, effort, mcpServers, hermeticDirs,
74
- processEnv, sessionContext, systemPromptText, promptText,
146
+ cwd, processCwd, model, effort, mcpServers, hermeticDirs,
147
+ processEnv, workerOptions, sessionId, sessionContext,
148
+ systemPromptText, promptText,
75
149
  });
76
150
  }
77
151
 
78
152
  async _start({
79
- cwd, model, effort, mcpServers, hermeticDirs,
80
- processEnv, sessionContext, systemPromptText, promptText,
153
+ cwd, processCwd, model, effort, mcpServers, hermeticDirs,
154
+ processEnv, workerOptions, sessionId, sessionContext,
155
+ systemPromptText, promptText,
81
156
  }) {
82
157
  let handle;
83
158
  try {
84
- handle = await this._pool.acquireWorker({
159
+ const acquireOptions = {
160
+ runtime: this._runtime,
85
161
  dispatchId: this._dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
86
162
  processEnv, sessionContext,
87
- });
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);
88
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
+ }
89
179
  // Mirrors child_process's 'error' event (spawn failed) — engine.js's
90
180
  // existing `proc.on('error', ...)` handler already does the right
91
181
  // thing (completeDispatch ERROR + cleanup) unmodified.
@@ -97,15 +187,44 @@ class PooledAgentProcess extends EventEmitter {
97
187
  // kill() landed while acquisition was still in flight. No OS process
98
188
  // was ever visible to anything downstream — just return the worker.
99
189
  try { handle.release(); } catch { /* best-effort */ }
190
+ this._finish(this._killExitCode ?? 143);
100
191
  return;
101
192
  }
102
193
 
103
194
  this._handle = handle;
104
195
  this.pid = typeof handle.pid === 'number' ? handle.pid : null;
196
+ if (handle.currentModel) {
197
+ this._writeRuntimeEvent({ kind: 'model', model: handle.currentModel });
198
+ }
199
+ this._expectedWorkerStartedAt = Number(handle.processStartedAt) || 0;
105
200
  // Lets engine.js write the PID-file equivalent (spawn-agent.js normally
106
201
  // does this itself, from inside the child process) as soon as a real OS
107
202
  // PID is known — see engine.js's pooled branch in spawnAgent().
108
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
+ }
109
228
 
110
229
  let sawError = false;
111
230
  try {
@@ -113,35 +232,51 @@ class PooledAgentProcess extends EventEmitter {
113
232
  systemPromptText,
114
233
  onChunk: (text) => {
115
234
  if (!text) return;
116
- this._writeStdoutEvent({ type: 'assistant.message_delta', data: { deltaContent: text } });
235
+ this._writeRuntimeEvent({ kind: 'chunk', text });
117
236
  },
118
237
  onDone: (result) => {
119
- this._writeStdoutEvent({
120
- type: 'result',
238
+ this._writeRuntimeEvent({
239
+ kind: 'result',
121
240
  sessionId: handle.sessionId || null,
122
- usage: {
123
- totalApiDurationMs: Date.now() - this._startedAt,
124
- stopReason: (result && result.stopReason) || null,
125
- },
241
+ durationMs: Date.now() - this._startedAt,
242
+ stopReason: (result && result.stopReason) || null,
126
243
  });
127
244
  },
128
245
  onError: (err) => {
129
246
  sawError = true;
130
247
  try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
131
248
  },
249
+ onToolUse: () => this._captureAroundToolCall(),
250
+ onToolUpdate: () => { void this._captureLeaseDescendants(); },
132
251
  });
133
252
  } catch (err) {
134
253
  sawError = true;
135
254
  try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
136
255
  } finally {
137
- 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 */ }
138
268
  this._handle = null;
139
269
  }
140
- this._finish(sawError ? 1 : 0);
270
+ this._finish(this._killExitCode ?? (sawError || this._runtimeOutputError ? 1 : 0));
141
271
  }
142
272
 
143
- _writeStdoutEvent(obj) {
144
- try { this.stdout.write(JSON.stringify(obj) + '\n'); } catch { /* best-effort */ }
273
+ _writeRuntimeEvent(event) {
274
+ try {
275
+ this.stdout.write(encodePooledOutput(this._runtime, event));
276
+ } catch (err) {
277
+ this._runtimeOutputError = err;
278
+ try { this.stderr.write(`Runtime output encoding failed: ${err.message}\n`); } catch {}
279
+ }
145
280
  }
146
281
 
147
282
  _finish(code) {
@@ -150,29 +285,361 @@ class PooledAgentProcess extends EventEmitter {
150
285
  this.exitCode = code;
151
286
  try { this.stdout.end(); } catch { /* best-effort */ }
152
287
  try { this.stderr.end(); } catch { /* best-effort */ }
288
+ this._resolveClose(code);
153
289
  this.emit('close', code);
154
290
  }
155
291
 
156
- // child_process.kill()-shaped primitive. Cancels the in-flight ACP turn
157
- // (if any) and releases the worker back to the pool rather than killing
158
- // the shared OS process — killing it would collaterally affect whatever
159
- // OTHER dispatch reuses that worker next (never a concurrent sibling, per
160
- // the pool's 1-worker:1-active-dispatch invariant, but still a real
161
- // 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.
162
622
  // Full timeout/steering-kill cancel-vs-kill wiring in engine/timeout.js is
163
623
  // P-6e2a4c15 — this method is the primitive that work item calls into.
164
624
  kill() {
165
625
  if (this.killed) return true;
166
626
  this.killed = true;
627
+ this._killExitCode = 143;
167
628
  if (this._handle) {
168
629
  try { this._handle.cancel(); } catch { /* best-effort */ }
169
- try { this._handle.release(); } catch { /* best-effort */ }
170
- 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);
171
636
  }
172
- // If acquisition is still in flight, `_start` observes `this.killed` and
173
- // releases the worker itself as soon as it lands — nothing more to do.
174
637
  return true;
175
638
  }
176
639
  }
177
640
 
178
- module.exports = { PooledAgentProcess };
641
+ module.exports = {
642
+ PooledAgentProcess,
643
+ dispatchOwnedDescendants, // exported for testing
644
+ _internals, // exported for testing
645
+ };