@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.
- package/bin/minions.js +1 -0
- package/dashboard/js/refresh.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +37 -19
- package/docs/completion-reports.md +20 -1
- package/docs/keep-processes.md +7 -2
- package/docs/live-checkout-mode.md +7 -7
- package/docs/managed-spawn.md +14 -1
- package/docs/runtime-adapters.md +61 -26
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +36 -0
- package/engine/acp-transport.js +273 -58
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +278 -54
- package/engine/cc-worker-pool.js +12 -3
- package/engine/cleanup.js +15 -11
- package/engine/cli.js +56 -28
- package/engine/comment-format.js +51 -14
- package/engine/create-pr-worktree.js +57 -79
- package/engine/dispatch.js +7 -2
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/keep-process-sweep.js +131 -9
- package/engine/lifecycle.js +126 -45
- package/engine/live-checkout.js +4 -2
- package/engine/llm.js +59 -83
- package/engine/managed-spawn.js +112 -5
- package/engine/memory-store.js +3 -1
- package/engine/playbook.js +4 -6
- package/engine/pooled-agent-process.js +512 -45
- package/engine/pr-action.js +6 -8
- package/engine/process-utils.js +154 -18
- package/engine/runtimes/claude.js +94 -25
- package/engine/runtimes/codex.js +1 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +97 -9
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +349 -82
- package/engine/spawn-agent.js +85 -116
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/timeout.js +14 -10
- package/engine/worktree-gc.js +55 -46
- package/engine.js +418 -241
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +2 -2
- package/prompts/cc-system.md +11 -11
- 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)
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
235
|
+
this._writeRuntimeEvent({ kind: 'chunk', text });
|
|
117
236
|
},
|
|
118
237
|
onDone: (result) => {
|
|
119
|
-
this.
|
|
120
|
-
|
|
238
|
+
this._writeRuntimeEvent({
|
|
239
|
+
kind: 'result',
|
|
121
240
|
sessionId: handle.sessionId || null,
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
try {
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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 = {
|
|
641
|
+
module.exports = {
|
|
642
|
+
PooledAgentProcess,
|
|
643
|
+
dispatchOwnedDescendants, // exported for testing
|
|
644
|
+
_internals, // exported for testing
|
|
645
|
+
};
|