@yemi33/minions 0.1.2372 → 0.1.2374
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/settings.js +1 -0
- package/dashboard.js +127 -112
- package/docs/design-inbox-entries-schema.md +106 -0
- package/docs/live-checkout-mode.md +27 -0
- package/docs/runtime-adapters.md +103 -0
- package/engine/acp-transport.js +594 -0
- package/engine/agent-worker-pool.js +367 -0
- package/engine/cc-worker-pool.js +38 -563
- package/engine/cleanup.js +34 -19
- package/engine/cli.js +19 -0
- package/engine/db/migrations/019-inbox-entries.js +37 -0
- package/engine/inbox-store.js +276 -0
- package/engine/live-checkout.js +168 -0
- package/engine/pooled-agent-process.js +158 -0
- package/engine/runtimes/copilot.js +5 -0
- package/engine/shared.js +75 -1
- package/engine/timeout.js +51 -7
- package/engine.js +81 -7
- package/package.json +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/pooled-agent-process.js — child_process-shaped facade over a leased
|
|
3
|
+
* engine/agent-worker-pool.js worker (P-1d8f0b93).
|
|
4
|
+
*
|
|
5
|
+
* engine.js#spawnAgent's downstream code (stdout/stderr `.on('data', ...)`
|
|
6
|
+
* handlers, the `onAgentClose` completion listener, PID-file/timeout
|
|
7
|
+
* bookkeeping, restart-reattach) is written entirely against the Node
|
|
8
|
+
* ChildProcess contract. Per the plan's explicit constraint ("do not fork
|
|
9
|
+
* spawnAgent into separate end-to-end completion implementations"),
|
|
10
|
+
* PooledAgentProcess wraps an agent-worker-pool.js lease behind that SAME
|
|
11
|
+
* shape — `.pid`, `.stdout`/`.stderr` (readable streams), `.kill()`,
|
|
12
|
+
* `.killed`, `.exitCode`, and `'close'`/`'error'` events — so spawnAgent's
|
|
13
|
+
* existing listener code (stdout/stderr capture, live-output.log writing,
|
|
14
|
+
* session-id capture, steering, trust-gate detection, archival) runs
|
|
15
|
+
* completely unmodified whether `proc` is a real child_process or a pooled
|
|
16
|
+
* ACP lease.
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const { EventEmitter } = require('events');
|
|
36
|
+
const { PassThrough } = require('stream');
|
|
37
|
+
|
|
38
|
+
class PooledAgentProcess extends EventEmitter {
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {object} opts.pool - engine/agent-worker-pool.js module (or a test double)
|
|
42
|
+
* @param {string} opts.dispatchId
|
|
43
|
+
* @param {string} opts.cwd
|
|
44
|
+
* @param {string} [opts.model]
|
|
45
|
+
* @param {string} [opts.effort]
|
|
46
|
+
* @param {Array} [opts.mcpServers]
|
|
47
|
+
* @param {Array<string>} [opts.hermeticDirs]
|
|
48
|
+
* @param {string} opts.promptText - fully-built prompt (runtime.buildPrompt output)
|
|
49
|
+
*/
|
|
50
|
+
constructor({ pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs, promptText }) {
|
|
51
|
+
super();
|
|
52
|
+
this.pid = null;
|
|
53
|
+
this.killed = false;
|
|
54
|
+
this.exitCode = null;
|
|
55
|
+
this.stdout = new PassThrough();
|
|
56
|
+
this.stderr = new PassThrough();
|
|
57
|
+
this._pool = pool;
|
|
58
|
+
this._dispatchId = dispatchId;
|
|
59
|
+
this._handle = null;
|
|
60
|
+
this._closed = false;
|
|
61
|
+
this._startedAt = Date.now();
|
|
62
|
+
|
|
63
|
+
// Fire-and-forget, mirroring child_process.spawn()'s async nature — the
|
|
64
|
+
// facade is usable (event listeners can be attached) immediately, before
|
|
65
|
+
// the underlying ACP worker has actually been acquired.
|
|
66
|
+
this._start({ cwd, model, effort, mcpServers, hermeticDirs, promptText });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _start({ cwd, model, effort, mcpServers, hermeticDirs, promptText }) {
|
|
70
|
+
let handle;
|
|
71
|
+
try {
|
|
72
|
+
handle = await this._pool.acquireWorker({
|
|
73
|
+
dispatchId: this._dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// Mirrors child_process's 'error' event (spawn failed) — engine.js's
|
|
77
|
+
// existing `proc.on('error', ...)` handler already does the right
|
|
78
|
+
// thing (completeDispatch ERROR + cleanup) unmodified.
|
|
79
|
+
this.emit('error', err);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.killed) {
|
|
84
|
+
// kill() landed while acquisition was still in flight. No OS process
|
|
85
|
+
// was ever visible to anything downstream — just return the worker.
|
|
86
|
+
try { handle.release(); } catch { /* best-effort */ }
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this._handle = handle;
|
|
91
|
+
this.pid = typeof handle.pid === 'number' ? handle.pid : null;
|
|
92
|
+
// Lets engine.js write the PID-file equivalent (spawn-agent.js normally
|
|
93
|
+
// does this itself, from inside the child process) as soon as a real OS
|
|
94
|
+
// PID is known — see engine.js's pooled branch in spawnAgent().
|
|
95
|
+
this.emit('acquired');
|
|
96
|
+
|
|
97
|
+
let sawError = false;
|
|
98
|
+
await handle.stream(promptText, {
|
|
99
|
+
onChunk: (text) => {
|
|
100
|
+
if (!text) return;
|
|
101
|
+
this._writeStdoutEvent({ type: 'assistant.message_delta', data: { deltaContent: text } });
|
|
102
|
+
},
|
|
103
|
+
onDone: (result) => {
|
|
104
|
+
this._writeStdoutEvent({
|
|
105
|
+
type: 'result',
|
|
106
|
+
sessionId: handle.sessionId || null,
|
|
107
|
+
usage: {
|
|
108
|
+
totalApiDurationMs: Date.now() - this._startedAt,
|
|
109
|
+
stopReason: (result && result.stopReason) || null,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
onError: (err) => {
|
|
114
|
+
sawError = true;
|
|
115
|
+
try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
try { handle.release(); } catch { /* best-effort */ }
|
|
120
|
+
this._finish(sawError ? 1 : 0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_writeStdoutEvent(obj) {
|
|
124
|
+
try { this.stdout.write(JSON.stringify(obj) + '\n'); } catch { /* best-effort */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_finish(code) {
|
|
128
|
+
if (this._closed) return;
|
|
129
|
+
this._closed = true;
|
|
130
|
+
this.exitCode = code;
|
|
131
|
+
try { this.stdout.end(); } catch { /* best-effort */ }
|
|
132
|
+
try { this.stderr.end(); } catch { /* best-effort */ }
|
|
133
|
+
this.emit('close', code);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// child_process.kill()-shaped primitive. Cancels the in-flight ACP turn
|
|
137
|
+
// (if any) and releases the worker back to the pool rather than killing
|
|
138
|
+
// the shared OS process — killing it would collaterally affect whatever
|
|
139
|
+
// OTHER dispatch reuses that worker next (never a concurrent sibling, per
|
|
140
|
+
// the pool's 1-worker:1-active-dispatch invariant, but still a real
|
|
141
|
+
// process other queued dispatches may be waiting to reuse).
|
|
142
|
+
// Full timeout/steering-kill cancel-vs-kill wiring in engine/timeout.js is
|
|
143
|
+
// P-6e2a4c15 — this method is the primitive that work item calls into.
|
|
144
|
+
kill() {
|
|
145
|
+
if (this.killed) return true;
|
|
146
|
+
this.killed = true;
|
|
147
|
+
if (this._handle) {
|
|
148
|
+
try { this._handle.cancel(); } catch { /* best-effort */ }
|
|
149
|
+
try { this._handle.release(); } catch { /* best-effort */ }
|
|
150
|
+
this._finish(this.exitCode == null ? 143 : this.exitCode);
|
|
151
|
+
}
|
|
152
|
+
// If acquisition is still in flight, `_start` observes `this.killed` and
|
|
153
|
+
// releases the worker itself as soon as it lands — nothing more to do.
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { PooledAgentProcess };
|
|
@@ -1285,6 +1285,11 @@ const capabilities = {
|
|
|
1285
1285
|
// `--attachment <path>` works in non-interactive mode (live-tested; see W-mqv7324u0021db5d).
|
|
1286
1286
|
// Base64 payloads are materialized to tmp files inside llmTmpDir before spawn.
|
|
1287
1287
|
imageInput: true,
|
|
1288
|
+
// P-9b3d5f61: `copilot --acp` speaks the Agent Client Protocol, so per-agent
|
|
1289
|
+
// dispatches can route through a persistent ACP worker pool
|
|
1290
|
+
// (engine/agent-worker-pool.js) instead of cold-spawning per dispatch.
|
|
1291
|
+
// Claude/Codex do not implement ACP — this flag is absent (falsy) there.
|
|
1292
|
+
acpWorkerPool: true,
|
|
1288
1293
|
};
|
|
1289
1294
|
|
|
1290
1295
|
// Install hint surfaced when `resolveBinary()` returns null. Covers all
|
package/engine/shared.js
CHANGED
|
@@ -2945,6 +2945,26 @@ function resolveLiveCheckoutAutoBaseRepair(project, engine) {
|
|
|
2945
2945
|
return false;
|
|
2946
2946
|
}
|
|
2947
2947
|
|
|
2948
|
+
// W-mrdlcud2000e630e (companion to the stale-base guard) — resolve the
|
|
2949
|
+
// live-checkout auto-freshen decision. Per-project `liveCheckoutAutoFreshen`
|
|
2950
|
+
// wins, else fleet-wide `engine.liveCheckoutAutoFreshen`, else false (opt-in).
|
|
2951
|
+
// When ON, prepareLiveCheckout — BEFORE the stale-base guard, and ONLY when the
|
|
2952
|
+
// tree is clean, HEAD is on mainRef, and the local mainRef has ZERO commits
|
|
2953
|
+
// ahead of origin/<mainRef> but IS behind it — runs `git fetch origin <mainRef>`
|
|
2954
|
+
// + a fast-forward-only update of the local mainRef so the new branch forks off
|
|
2955
|
+
// the freshest safe base. This is the ONLY live-mode path that fetches/mutates
|
|
2956
|
+
// local mainRef; the ahead>0 stale-base refusal is untouched, and a fetch
|
|
2957
|
+
// failure fails open to the pre-existing (un-freshened) behavior. Default OFF.
|
|
2958
|
+
function resolveLiveCheckoutAutoFreshen(project, engine) {
|
|
2959
|
+
if (project && typeof project === 'object' && typeof project.liveCheckoutAutoFreshen === 'boolean') {
|
|
2960
|
+
return project.liveCheckoutAutoFreshen;
|
|
2961
|
+
}
|
|
2962
|
+
if (engine && typeof engine === 'object' && typeof engine.liveCheckoutAutoFreshen === 'boolean') {
|
|
2963
|
+
return engine.liveCheckoutAutoFreshen;
|
|
2964
|
+
}
|
|
2965
|
+
return false;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2948
2968
|
function validateCheckoutMode(value) {
|
|
2949
2969
|
if (value === undefined || value === null || value === '') return undefined;
|
|
2950
2970
|
if (typeof value !== 'string') {
|
|
@@ -3466,6 +3486,7 @@ const ENGINE_DEFAULTS = {
|
|
|
3466
3486
|
copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
|
|
3467
3487
|
copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
|
|
3468
3488
|
ccUseWorkerPool: false, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. Off by default — opt-in feature flag. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
|
|
3489
|
+
agentUseWorkerPool: false, // P-9b3d5f61: mirrors ccUseWorkerPool's shape but for the fleet agent-dispatch path (engine/agent-worker-pool.js). Opt-in, default OFF — see resolveAgentUseWorkerPool in engine/shared.js for the full priority chain and rationale.
|
|
3469
3490
|
// PR #321 — "kill auth-popup storm". Copilot spawns and authenticates EVERY
|
|
3470
3491
|
// MCP server in `$COPILOT_HOME/mcp-config.json` on every process start, even
|
|
3471
3492
|
// when the corresponding tool is hidden from the model via
|
|
@@ -3845,6 +3866,58 @@ function resolveCcUseWorkerPool(engine) {
|
|
|
3845
3866
|
return true; // CC runtime is copilot — default ON (cold-start savings)
|
|
3846
3867
|
}
|
|
3847
3868
|
|
|
3869
|
+
/**
|
|
3870
|
+
* P-9b3d5f61 — Resolve whether a per-agent spawn should route through the
|
|
3871
|
+
* persistent ACP worker pool (engine/agent-worker-pool.js). Mirrors
|
|
3872
|
+
* resolveCcUseWorkerPool's shape but takes the already-resolved runtime
|
|
3873
|
+
* adapter (as returned by `resolveRuntime(resolveAgentCli(agent, engine))`)
|
|
3874
|
+
* rather than re-deriving it, since callers on the agent-spawn path already
|
|
3875
|
+
* have it in hand. Priority:
|
|
3876
|
+
* 1. Capability guard — pool transport is ACP-only (`copilot --acp`). Hard
|
|
3877
|
+
* false whenever `runtime.capabilities.acpWorkerPool` isn't `true`,
|
|
3878
|
+
* regardless of operator override, so a future non-ACP runtime can never
|
|
3879
|
+
* be silently routed through the pool (same rationale as
|
|
3880
|
+
* resolveCcUseWorkerPool's CC-runtime guard, W-mphlriic00095f69).
|
|
3881
|
+
* 2. `engine.agentUseWorkerPool` explicit true/false — operator override
|
|
3882
|
+
* wins *within* an ACP-capable runtime.
|
|
3883
|
+
* 3. `ENGINE_DEFAULTS.agentUseWorkerPool` (false) — opt-in, default OFF.
|
|
3884
|
+
* Deliberately different from CC's default-true: the fleet dispatch path
|
|
3885
|
+
* has a much larger MCP-auth-popup / isolation surface than the single
|
|
3886
|
+
* CC singleton, so this stays off until proven safe.
|
|
3887
|
+
*/
|
|
3888
|
+
function resolveAgentUseWorkerPool(engine, runtime) {
|
|
3889
|
+
if (!runtime || !runtime.capabilities || runtime.capabilities.acpWorkerPool !== true) return false;
|
|
3890
|
+
if (engine && (engine.agentUseWorkerPool === true || engine.agentUseWorkerPool === false)) {
|
|
3891
|
+
return engine.agentUseWorkerPool;
|
|
3892
|
+
}
|
|
3893
|
+
return ENGINE_DEFAULTS.agentUseWorkerPool;
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
/**
|
|
3897
|
+
* P-9b3d5f61 — Resolve the max concurrent ACP workers the per-agent pool
|
|
3898
|
+
* (engine/agent-worker-pool.js) should keep warm. Priority:
|
|
3899
|
+
* 1. `engine.agentAcpPoolSize` — explicit operator override
|
|
3900
|
+
* 2. `engine.maxConcurrent` — fleet dispatch concurrency cap (so the
|
|
3901
|
+
* pool never queues more than the engine would have dispatched anyway)
|
|
3902
|
+
* 3. `ENGINE_DEFAULTS.maxConcurrent` — hardcoded fallback
|
|
3903
|
+
*
|
|
3904
|
+
* Non-numeric or non-positive overrides are ignored and fall through to the
|
|
3905
|
+
* next tier (defensive against bad Settings-UI/env input).
|
|
3906
|
+
*/
|
|
3907
|
+
function resolveAgentAcpPoolSize(engine) {
|
|
3908
|
+
const explicit = engine ? engine.agentAcpPoolSize : undefined;
|
|
3909
|
+
if (explicit !== undefined && explicit !== null) {
|
|
3910
|
+
const n = typeof explicit === 'number' ? explicit : Number(explicit);
|
|
3911
|
+
if (!Number.isNaN(n) && n > 0) return n;
|
|
3912
|
+
}
|
|
3913
|
+
const fleet = engine ? engine.maxConcurrent : undefined;
|
|
3914
|
+
if (fleet !== undefined && fleet !== null) {
|
|
3915
|
+
const n = typeof fleet === 'number' ? fleet : Number(fleet);
|
|
3916
|
+
if (!Number.isNaN(n) && n > 0) return n;
|
|
3917
|
+
}
|
|
3918
|
+
return ENGINE_DEFAULTS.maxConcurrent;
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3848
3921
|
/**
|
|
3849
3922
|
* Resolve the model for a per-agent spawn. Priority:
|
|
3850
3923
|
* 1. `agent.model` — per-agent override
|
|
@@ -8981,7 +9054,7 @@ module.exports = {
|
|
|
8981
9054
|
isEngineSystemAlert,
|
|
8982
9055
|
ENGINE_DEFAULTS,
|
|
8983
9056
|
resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
|
|
8984
|
-
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
|
|
9057
|
+
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentUseWorkerPool, resolveAgentAcpPoolSize, resolveAgentModel, resolveCcModel,
|
|
8985
9058
|
resolveAgentMaxBudget, resolveAgentBareMode,
|
|
8986
9059
|
resolveCopilotAgentDisabledMcpServers,
|
|
8987
9060
|
applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
|
|
@@ -9120,6 +9193,7 @@ module.exports = {
|
|
|
9120
9193
|
isLiveCheckoutProject,
|
|
9121
9194
|
resolveLiveCheckoutAutoReset,
|
|
9122
9195
|
resolveLiveCheckoutAutoBaseRepair,
|
|
9196
|
+
resolveLiveCheckoutAutoFreshen,
|
|
9123
9197
|
validatePid,
|
|
9124
9198
|
PR_FIX_CAUSE,
|
|
9125
9199
|
getPrFixAutomationCause,
|
package/engine/timeout.js
CHANGED
|
@@ -234,6 +234,31 @@ function checkDeferredStranded(activeProcesses, config) {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// P-6e2a4c15 — cancel-vs-kill for pooled dispatches. A pooled dispatch's
|
|
238
|
+
// `activeProcesses` record (`info`) carries `_pooled: true` (stamped by
|
|
239
|
+
// engine.js#spawnAgent, P-1d8f0b93) and its `.proc` is a PooledAgentProcess
|
|
240
|
+
// facade whose `.pid` is the SHARED, long-lived ACP worker's real OS pid —
|
|
241
|
+
// not a per-dispatch child process. Every OS-level kill helper in this file
|
|
242
|
+
// (`shared.killGracefully`, `shared.killImmediate`, `_escalatePlatformKill`)
|
|
243
|
+
// ultimately runs `taskkill /F /T /PID <pid>` or SIGKILLs that pid's whole
|
|
244
|
+
// process tree. Doing that to a pooled dispatch would kill the shared worker
|
|
245
|
+
// out from under every OTHER dispatch waiting to reuse it from the pool —
|
|
246
|
+
// exactly the collateral-damage risk `PooledAgentProcess#kill()` (P-1d8f0b93)
|
|
247
|
+
// was built to avoid: it cancels the in-flight ACP turn and releases the
|
|
248
|
+
// worker back to the pool without touching the OS process. Route every kill
|
|
249
|
+
// request here through this helper so no call site can accidentally bypass
|
|
250
|
+
// that primitive for a pooled record.
|
|
251
|
+
function killTrackedProcess(info, { graceMs } = {}) {
|
|
252
|
+
if (!info || !info.proc) return;
|
|
253
|
+
if (info._pooled && typeof info.proc.kill === 'function') {
|
|
254
|
+
try { info.proc.kill(); }
|
|
255
|
+
catch (err) { try { log('warn', `Pooled dispatch cancel failed for ${info.agentId || '?'}: ${err.message}`); } catch {} }
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (graceMs == null) shared.killImmediate(info.proc);
|
|
259
|
+
else shared.killGracefully(info.proc, graceMs);
|
|
260
|
+
}
|
|
261
|
+
|
|
237
262
|
// W-mq066js7000fff1f-c (Gap C): runs the kill-retry escalation ladder for an
|
|
238
263
|
// already-steered process whose `_steeringAt` is set but which hasn't exited.
|
|
239
264
|
// Caller is `checkSteering` per-tick loop; `info` is the `activeProcesses`
|
|
@@ -253,7 +278,7 @@ function _runSteeringKillLadder(id, info, config) {
|
|
|
253
278
|
if (attempts < cap) {
|
|
254
279
|
if (attempts === 0) {
|
|
255
280
|
log('warn', `Steering: ${info.agentId} (${id}) didn't exit ${Math.round(wait / 1000)}s after kill — retrying gracefully`);
|
|
256
|
-
try {
|
|
281
|
+
try { killTrackedProcess(info, { graceMs: 5000 }); }
|
|
257
282
|
catch (err) { try { log('warn', `Steering kill retry (graceful) failed for ${info.agentId}: ${err.message}`); } catch {} }
|
|
258
283
|
} else {
|
|
259
284
|
log('warn', `Steering: ${info.agentId} (${id}) survived attempt ${attempts} — escalating (attempt ${attempts + 1}/${cap})`);
|
|
@@ -287,6 +312,13 @@ function _runSteeringKillLadder(id, info, config) {
|
|
|
287
312
|
}
|
|
288
313
|
|
|
289
314
|
function _escalatePlatformKill(info) {
|
|
315
|
+
// Pooled dispatches never reach the OS-level escalation path — cancel the
|
|
316
|
+
// ACP turn and release the worker instead of taskkill/SIGKILLing the
|
|
317
|
+
// shared worker process. See killTrackedProcess's comment above.
|
|
318
|
+
if (info?._pooled && info.proc && typeof info.proc.kill === 'function') {
|
|
319
|
+
try { info.proc.kill(); } catch { /* best-effort */ }
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
290
322
|
const pid = info?.proc?.pid;
|
|
291
323
|
if (!pid) return;
|
|
292
324
|
if (process.platform === 'win32') {
|
|
@@ -418,7 +450,7 @@ function checkSteering(config) {
|
|
|
418
450
|
fs.appendFileSync(liveLogPath, `\n[steering] Message received but no session to resume. Killing agent — your message will be delivered on retry.\n`);
|
|
419
451
|
} catch { /* optional */ }
|
|
420
452
|
|
|
421
|
-
|
|
453
|
+
killTrackedProcess(info);
|
|
422
454
|
info._steeringAt = Date.now();
|
|
423
455
|
info._steeringNoSession = true;
|
|
424
456
|
continue;
|
|
@@ -442,7 +474,7 @@ function checkSteering(config) {
|
|
|
442
474
|
} catch { /* best-effort */ }
|
|
443
475
|
}
|
|
444
476
|
|
|
445
|
-
|
|
477
|
+
killTrackedProcess(info);
|
|
446
478
|
}
|
|
447
479
|
}
|
|
448
480
|
|
|
@@ -619,7 +651,7 @@ function checkTimeouts(config) {
|
|
|
619
651
|
if (elapsed > itemTimeout) {
|
|
620
652
|
log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
|
|
621
653
|
dispatch().updateAgentStatus(id, AGENT_STATUS.TIMED_OUT, `Hard timeout after ${Math.round(elapsed / 1000)}s`);
|
|
622
|
-
|
|
654
|
+
killTrackedProcess(info, { graceMs: 5000 });
|
|
623
655
|
}
|
|
624
656
|
}
|
|
625
657
|
|
|
@@ -700,7 +732,7 @@ function checkTimeouts(config) {
|
|
|
700
732
|
runPostCompletionHooks(item, item.agent, processExitCode, fullLogForHooks, config, { detectPhantom: true, expectedNonce, completionNonceRequired }).catch(e => log('warn', 'post-completion hooks: ' + e.message));
|
|
701
733
|
|
|
702
734
|
if (hasProcess) {
|
|
703
|
-
|
|
735
|
+
killTrackedProcess(activeProcesses.get(item.id));
|
|
704
736
|
activeProcesses.delete(item.id);
|
|
705
737
|
}
|
|
706
738
|
}
|
|
@@ -815,7 +847,7 @@ function checkTimeouts(config) {
|
|
|
815
847
|
// Clear the cached session so retry does not re-enter the same stuck --resume path.
|
|
816
848
|
try { shared.safeUnlink(path.join(AGENTS_DIR, item.agent, 'session.json')); } catch {}
|
|
817
849
|
activeProcesses.delete(item.id);
|
|
818
|
-
|
|
850
|
+
killTrackedProcess(procInfo, { graceMs: 5000 });
|
|
819
851
|
deadItems.push({ item, reason, failureClass: FAILURE_CLASS.TIMEOUT });
|
|
820
852
|
}
|
|
821
853
|
}
|
|
@@ -867,7 +899,18 @@ function checkTimeouts(config) {
|
|
|
867
899
|
|
|
868
900
|
if (!processAlive && (canReapDeadProcess || silentMs > staleOrphanTimeout) && (Date.now() > engineRestartGraceUntil || canReapDeadProcess)) {
|
|
869
901
|
// Last-resort PID check: lost tracked handle but OS process may still be alive.
|
|
870
|
-
|
|
902
|
+
// P-3f5b7d26 — SKIPPED for pooled dispatches (item.pooled, persisted by
|
|
903
|
+
// engine.js#spawnAgent). The PID recorded for a pooled dispatch is the
|
|
904
|
+
// shared ACP worker's real OS pid (engine/acp-transport.js#spawnAcp),
|
|
905
|
+
// whose command line is literally `copilot --acp ...` — it would always
|
|
906
|
+
// pass isProcessCommandLineMatchingAgent's `cmdline.includes('copilot')`
|
|
907
|
+
// check below even though that worker has no relationship to THIS
|
|
908
|
+
// dispatch (a fresh engine's agent-worker-pool.js always cold-starts
|
|
909
|
+
// empty; the pool never reconnects a restart-era worker to any prior
|
|
910
|
+
// dispatch). Without this guard a coincidentally-still-alive (or
|
|
911
|
+
// PID-recycled) worker process would be misread as "still running",
|
|
912
|
+
// parking the dispatch forever instead of routing it to retry.
|
|
913
|
+
if (!item.pooled && isOsPidAliveForDispatch(item.id)) {
|
|
871
914
|
// Cross-check the PID's command line: a recycled OS PID can be alive
|
|
872
915
|
// under an unrelated process (Windows reuses PIDs aggressively). Without
|
|
873
916
|
// this cmdline gate, the dispatch is parked indefinitely as 'active'
|
|
@@ -1018,4 +1061,5 @@ module.exports = {
|
|
|
1018
1061
|
_resetSteeringStoreCacheForTest, _setSteeringStoreForTest,
|
|
1019
1062
|
// exported for testing — steering clamp helpers and deferred checkpoint
|
|
1020
1063
|
_clampSteeringDeferredMaxMs, _clampSteeringMaxKillRetries, _appendLiveOutputLine, deferSteeringUntilCheckpoint,
|
|
1064
|
+
killTrackedProcess, _escalatePlatformKill, // exported for testing — P-6e2a4c15 cancel-vs-kill
|
|
1021
1065
|
};
|
package/engine.js
CHANGED
|
@@ -36,6 +36,12 @@ const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
|
|
|
36
36
|
FAILURE_CLASS, resolvePollFlag } = shared;
|
|
37
37
|
const { resolveRuntime } = require('./engine/runtimes');
|
|
38
38
|
const { assertStaleHeadOk, preApproveWorkspaceMcps } = require('./engine/spawn-agent');
|
|
39
|
+
// P-1d8f0b93 — fleet ACP worker pool + its child_process-shaped facade.
|
|
40
|
+
// NOTE: engine/agent-worker-pool.js is a DIFFERENT module from the
|
|
41
|
+
// dashboard's single-tab CC pool (engine.js must never import that one —
|
|
42
|
+
// see the cc-tab-pool wiring regression guard test).
|
|
43
|
+
const agentWorkerPool = require('./engine/agent-worker-pool');
|
|
44
|
+
const { PooledAgentProcess } = require('./engine/pooled-agent-process');
|
|
39
45
|
const adoGitAuth = require('./engine/ado-git-auth');
|
|
40
46
|
const queries = require('./engine/queries');
|
|
41
47
|
const dispatchEvents = require('./engine/dispatch-events');
|
|
@@ -4458,6 +4464,18 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4458
4464
|
const resolvedModel = runtime.resolveModel(shared.resolveAgentModel(agentConfig, engineConfig));
|
|
4459
4465
|
const resolvedMaxBudget = shared.resolveAgentMaxBudget(agentConfig, engineConfig);
|
|
4460
4466
|
const resolvedBare = shared.resolveAgentBareMode(agentConfig, engineConfig);
|
|
4467
|
+
// P-1d8f0b93 — pool a copilot dispatch through the persistent ACP worker
|
|
4468
|
+
// pool (engine/agent-worker-pool.js) instead of cold-spawning a fresh CLI
|
|
4469
|
+
// process, when the capability + config gate allows it. Structurally
|
|
4470
|
+
// copilot-only (runtime.capabilities.acpWorkerPool) — see
|
|
4471
|
+
// shared.resolveAgentUseWorkerPool for the exact precedence chain.
|
|
4472
|
+
const useAgentPool = shared.resolveAgentUseWorkerPool(engineConfig, runtime);
|
|
4473
|
+
if (useAgentPool) {
|
|
4474
|
+
// Idempotent — cheap to call on every pooled dispatch so a live config
|
|
4475
|
+
// change (agentAcpPoolSize / maxConcurrent) takes effect without a
|
|
4476
|
+
// separate engine-restart-only wiring hook.
|
|
4477
|
+
agentWorkerPool.setPoolSize(shared.resolveAgentAcpPoolSize(engineConfig));
|
|
4478
|
+
}
|
|
4461
4479
|
|
|
4462
4480
|
// W-mpg6isvy000xca4d — On retry after FAILURE_CLASS.MODEL_UNAVAILABLE, swap
|
|
4463
4481
|
// to the runtime-appropriate fallback model. Two paths gated on
|
|
@@ -4582,7 +4600,14 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4582
4600
|
childEnv.MINIONS_NO_AUTO_OPEN = '1';
|
|
4583
4601
|
// MCP-free copilot config so agents never pop a per-spawn Microsoft auth
|
|
4584
4602
|
// window — see _applyAgentCopilotHome / shared.resolveAgentCopilotHome.
|
|
4585
|
-
|
|
4603
|
+
// P-1d8f0b93 — SKIPPED for pooled dispatches: the pooled ACP worker is a
|
|
4604
|
+
// long-lived process shared across many dispatches, so it always uses the
|
|
4605
|
+
// operator's REAL COPILOT_HOME (this is the isolation-removal half of the
|
|
4606
|
+
// pooling feature — MCP-auth-popup risk becomes bounded by pool size
|
|
4607
|
+
// instead of eliminated per-dispatch; documented tradeoff in the plan).
|
|
4608
|
+
if (!useAgentPool) {
|
|
4609
|
+
_applyAgentCopilotHome(childEnv);
|
|
4610
|
+
}
|
|
4586
4611
|
// Keep agent scratch off a (possibly full) system drive — anchors TEMP/TMP to
|
|
4587
4612
|
// the worktree volume. See _applyAgentTempEnv / shared.resolveAgentTempBaseDir.
|
|
4588
4613
|
_applyAgentTempEnv(childEnv, project);
|
|
@@ -4614,6 +4639,14 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4614
4639
|
const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
4615
4640
|
|
|
4616
4641
|
const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
|
|
4642
|
+
// P-1d8f0b93 — the pooled-dispatch branch further down (agentWorkerPool
|
|
4643
|
+
// acquireWorker) accepts an optional `hermeticDirs` (workspace-manifest
|
|
4644
|
+
// `allowed_repos` beyond the primary cwd — see engine/agent-worker-pool.js
|
|
4645
|
+
// header doc). Repository harness discovery (skills/commands/--add-dir) is
|
|
4646
|
+
// now delegated entirely to the native runtime CLI (#817), so there is no
|
|
4647
|
+
// longer an engine-computed add-dir set to reuse here; leave it empty and
|
|
4648
|
+
// let agentWorkerPool's `_applyHermeticDirs` no-op as designed.
|
|
4649
|
+
const addDirs = [];
|
|
4617
4650
|
|
|
4618
4651
|
// Live output file — stamped BEFORE child process is spawned (#W-mo248lkjwgsu).
|
|
4619
4652
|
// Writing the stub pre-spawn lets the orphan detector distinguish three failure modes
|
|
@@ -4687,12 +4720,43 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4687
4720
|
// exactly — the agent's process already starts here, but cd-ing around or
|
|
4688
4721
|
// sub-shells can drift, so the env var is the stable anchor.
|
|
4689
4722
|
childEnv.MINIONS_AGENT_CWD = cwd;
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4723
|
+
if (!useAgentPool) {
|
|
4724
|
+
proc = runFile(process.execPath, spawnArgs, {
|
|
4725
|
+
cwd,
|
|
4726
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
4727
|
+
env: childEnv,
|
|
4728
|
+
detached: true,
|
|
4729
|
+
});
|
|
4730
|
+
} else {
|
|
4731
|
+
// P-1d8f0b93 — pooled copilot dispatch. No separate spawn-agent.js
|
|
4732
|
+
// child process exists, so build the SAME combined prompt text that
|
|
4733
|
+
// process would have received via runtime.buildPrompt(...) directly
|
|
4734
|
+
// here, and hand it to a PooledAgentProcess facade instead of
|
|
4735
|
+
// cold-spawning. Everything downstream (stdout/stderr handlers,
|
|
4736
|
+
// onAgentClose, live-output.log, PID-file writing below) treats `proc`
|
|
4737
|
+
// identically to a real child_process either way.
|
|
4738
|
+
const pooledPromptText = runtime.buildPrompt(fullTaskPrompt, systemPrompt, { sessionId: cachedSessionId });
|
|
4739
|
+
proc = new PooledAgentProcess({
|
|
4740
|
+
pool: agentWorkerPool,
|
|
4741
|
+
dispatchId: id,
|
|
4742
|
+
cwd,
|
|
4743
|
+
model: effectiveModel,
|
|
4744
|
+
effort: requestedEffort,
|
|
4745
|
+
// Mirrors the CC worker pool's convention (dashboard.js) — a single
|
|
4746
|
+
// flat top-level config array, not per-agent-resolved.
|
|
4747
|
+
mcpServers: (engineConfig && engineConfig.mcpServers) || [],
|
|
4748
|
+
hermeticDirs: addDirs,
|
|
4749
|
+
promptText: pooledPromptText,
|
|
4750
|
+
});
|
|
4751
|
+
// The child_process PID file is normally written asynchronously by
|
|
4752
|
+
// spawn-agent.js itself (engine/spawn-agent.js:606) once it starts.
|
|
4753
|
+
// There is no such child process here, so the engine writes the
|
|
4754
|
+
// equivalent file itself as soon as the pool hands back a real OS PID
|
|
4755
|
+
// (mirrors that write: async, best-effort, same `String(pid)` shape).
|
|
4756
|
+
proc.on('acquired', () => {
|
|
4757
|
+
try { fs.writeFile(pidFilePath, String(proc.pid || ''), () => {}); } catch { /* best-effort */ }
|
|
4758
|
+
});
|
|
4759
|
+
}
|
|
4696
4760
|
_phaseT.spawnCallEnd = Date.now();
|
|
4697
4761
|
|
|
4698
4762
|
// Seed realActivityMap and stamp PID immediately — BEFORE any handlers (#W-mo25loq8kjer).
|
|
@@ -4720,6 +4784,11 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4720
4784
|
_runtimeResumeAwaitingFirstOutput: true,
|
|
4721
4785
|
} : {}),
|
|
4722
4786
|
_pendingSteeringFiles: pendingSteering.entries,
|
|
4787
|
+
// P-1d8f0b93 — marks a pooled dispatch so engine/timeout.js's
|
|
4788
|
+
// cancel-vs-kill wiring (P-6e2a4c15, killTrackedProcess /
|
|
4789
|
+
// _escalatePlatformKill) can route `.kill()` to cancel+release the
|
|
4790
|
+
// ACP worker instead of SIGKILL/taskkill on the shared worker's OS pid.
|
|
4791
|
+
...(useAgentPool ? { _pooled: true } : {}),
|
|
4723
4792
|
};
|
|
4724
4793
|
activeProcesses.set(id, initialProcInfo);
|
|
4725
4794
|
registeredInActiveProcesses = true;
|
|
@@ -6008,6 +6077,11 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6008
6077
|
// route output parsing through the right adapter. Also surfaces the choice
|
|
6009
6078
|
// in dispatch.json for debugging multi-runtime fleets.
|
|
6010
6079
|
item.runtimeName = runtimeName;
|
|
6080
|
+
// P-3f5b7d26 — persist pooled on the DISK record (activeProcesses/_pooled
|
|
6081
|
+
// is wiped on restart); cli.js's reattach path needs this since a pooled
|
|
6082
|
+
// ACP worker isn't spawned detached and a fresh pool cold-starts empty —
|
|
6083
|
+
// a live worker PID must never be mistaken for a resumable dispatch.
|
|
6084
|
+
if (useAgentPool) item.pooled = true;
|
|
6011
6085
|
// W-mq5rwwss000f30a7 — persist the worktree path so the live-worktree
|
|
6012
6086
|
// guard (shared.isWorktreePathLive) can correlate destructive callers
|
|
6013
6087
|
// (removeWorktree, cleanup orphan-dir sweep, pool-return, quarantine)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2374",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|