@yemi33/minions 0.1.2383 → 0.1.2385
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/dashboard/js/refresh.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +133 -46
- 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 +7 -4
- package/docs/worktree-lifecycle.md +22 -0
- package/engine/acp-transport.js +216 -29
- package/engine/agent-worker-pool.js +268 -51
- package/engine/cc-worker-pool.js +8 -1
- package/engine/cleanup.js +15 -11
- package/engine/cli.js +47 -27
- package/engine/create-pr-worktree.js +57 -79
- package/engine/keep-process-sweep.js +131 -9
- package/engine/lifecycle.js +1 -1
- package/engine/live-checkout.js +4 -2
- package/engine/managed-spawn.js +112 -5
- package/engine/pooled-agent-process.js +486 -21
- package/engine/pr-action.js +6 -8
- package/engine/process-utils.js +154 -18
- package/engine/runtimes/copilot.js +22 -4
- package/engine/shared.js +254 -61
- package/engine/spawn-agent.js +6 -3
- package/engine/timeout.js +14 -10
- package/engine/worktree-gc.js +55 -46
- package/engine.js +237 -134
- package/package.json +1 -1
- package/playbooks/shared-rules.md +2 -2
- package/prompts/cc-system.md +11 -11
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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 = {
|
|
641
|
+
module.exports = {
|
|
642
|
+
PooledAgentProcess,
|
|
643
|
+
dispatchOwnedDescendants, // exported for testing
|
|
644
|
+
_internals, // exported for testing
|
|
645
|
+
};
|
package/engine/pr-action.js
CHANGED
|
@@ -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:
|
|
188
|
-
// isolated worktree
|
|
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
|
|
193
|
-
`This project uses isolated worktrees, so do NOT commit, branch, or push in its
|
|
194
|
-
`Steps: (1) call POST /api/pr-action/prepare-create-pr-worktree with {"project":"${proj}"} — the server
|
|
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
|
-
`
|
|
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
|