@yemi33/minions 0.1.2371 → 0.1.2373
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/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/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
package/engine/cc-worker-pool.js
CHANGED
|
@@ -21,18 +21,19 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Internal model (one process per tab):
|
|
23
23
|
*
|
|
24
|
-
* tabs: Map<tabId, Worker>
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* pending: Map<id, { resolve, reject }>, inflight, readBuf, nextReqId
|
|
28
|
-
* }
|
|
24
|
+
* tabs: Map<tabId, Worker> — Worker (engine/acp-transport.js) owns the
|
|
25
|
+
* spawn + JSON-RPC framing; this module owns per-tab lifecycle policy:
|
|
26
|
+
* eviction rules, idle reaping, and the warm-prewarm FIFO queue.
|
|
29
27
|
*
|
|
30
28
|
* TODO (sub-task C/D follow-up): multiplex N tabs onto one ACP process
|
|
31
29
|
* (`session/new` per tab, single `proc`). ACP supports it; left as a
|
|
32
30
|
* follow-up perf win once one-process-per-tab is proven in the dashboard.
|
|
33
31
|
*
|
|
34
32
|
* Eviction rules:
|
|
35
|
-
* - mcpServers changes → kill proc entirely + respawn
|
|
33
|
+
* - mcpServers changes → kill proc entirely + respawn (confirmed
|
|
34
|
+
* necessary for LOCAL/stdio MCP servers, which
|
|
35
|
+
* are resolved from $COPILOT_HOME/mcp-config.json
|
|
36
|
+
* at process boot — see P-7a1c2e4f spike, (c)).
|
|
36
37
|
* - systemPromptHash change → keep proc, send a fresh `session/new`
|
|
37
38
|
* - model / effort change → no eviction (state updated in place)
|
|
38
39
|
* - closeTab → `session/cancel` if inflight, then kill proc
|
|
@@ -49,54 +50,37 @@
|
|
|
49
50
|
* is complete (...)"
|
|
50
51
|
* so the dashboard can surface a precise reason instead of a generic timeout.
|
|
51
52
|
*
|
|
52
|
-
* No new npm deps — Node built-ins only (
|
|
53
|
+
* No new npm deps — Node built-ins only (events, timers).
|
|
53
54
|
*
|
|
54
55
|
* See `notes/archive/2026-05-13-ripley-W-mp3h2wq2000e64f7-2026-05-13-0301.md`
|
|
55
56
|
* for the ACP protocol probe (line framing, request/response correlation,
|
|
56
|
-
* cold-vs-warm timings, cancel semantics, configOptions surface)
|
|
57
|
+
* cold-vs-warm timings, cancel semantics, configOptions surface) and
|
|
58
|
+
* `notes/archive/2026-07-10-ripley-P-7a1c2e4f-2026-07-10-0141.md` for the
|
|
59
|
+
* follow-up spike confirming cancel-then-reuse is safe and the `stopReason`
|
|
60
|
+
* on a cancelled turn is `end_turn` (not `cancelled`) on copilot CLI 1.0.70.
|
|
57
61
|
*/
|
|
58
62
|
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const ERROR_CODES = Object.freeze({
|
|
69
|
-
// spawn() threw synchronously OR the child process emitted an 'error'
|
|
70
|
-
// event (binary missing on PATH, exec failed, EPERM, etc.). Retriable
|
|
71
|
-
// because a transient PATH / fs glitch may recover.
|
|
72
|
-
WORKER_SPAWN_FAILED: 'worker-spawn-failed',
|
|
73
|
-
// The worker process exited DURING the ACP handshake (initialize or
|
|
74
|
-
// session/new) — usually `copilot login` is incomplete or the CLI
|
|
75
|
-
// version is too old. Also fires when session/new returns no
|
|
76
|
-
// sessionId. Retriable: the engine swaps to a fallback model / a re-auth
|
|
77
|
-
// may unblock the next attempt.
|
|
78
|
-
ACP_HANDSHAKE_FAILED: 'acp-handshake-failed',
|
|
79
|
-
// The worker process exited AFTER a successful handshake (the daemon
|
|
80
|
-
// died mid-turn). Retriable — the next call cold-spawns a fresh worker.
|
|
81
|
-
WORKER_DIED: 'worker-died',
|
|
82
|
-
// The consumer's per-turn timeout fired before the ACP session/prompt
|
|
83
|
-
// resolved. Owned by the dashboard pool wrappers (cc-worker-pool itself
|
|
84
|
-
// has no turn timeout) but exported here so all callers stringify the
|
|
85
|
-
// same constant. Retriable — most timeouts are transient.
|
|
86
|
-
CC_TURN_TIMEOUT: 'cc-turn-timeout',
|
|
87
|
-
});
|
|
63
|
+
const transport = require('./acp-transport');
|
|
64
|
+
const {
|
|
65
|
+
ERROR_CODES,
|
|
66
|
+
typedError: _typedError,
|
|
67
|
+
hashMcpServers: _hashMcpServers,
|
|
68
|
+
buildSessionNewParams: _buildSessionNewParams,
|
|
69
|
+
Worker,
|
|
70
|
+
WARM_MAX_CONCURRENT,
|
|
71
|
+
} = transport;
|
|
88
72
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return
|
|
99
|
-
}
|
|
73
|
+
// Test seam — every external side effect goes through `_internals` so
|
|
74
|
+
// test/unit/cc-worker-pool.test.js can stub spawn/now/killImmediate. This
|
|
75
|
+
// object is passed to each Worker constructed by this pool (constructor
|
|
76
|
+
// injection, engine/acp-transport.js#Worker) rather than the transport
|
|
77
|
+
// reaching for a module-level singleton — that keeps this pool's test stubs
|
|
78
|
+
// isolated from engine/agent-worker-pool.js's own `_internals`.
|
|
79
|
+
const _internals = {
|
|
80
|
+
spawnAcp: transport.spawnAcp,
|
|
81
|
+
killImmediate: transport.killImmediate,
|
|
82
|
+
now() { return Date.now(); },
|
|
83
|
+
};
|
|
100
84
|
|
|
101
85
|
// 30 minutes default — matches ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs. This is
|
|
102
86
|
// now a runtime-configurable knob (W-mr0qs0vw): the pool stays dependency-free
|
|
@@ -109,56 +93,6 @@ let _idleReaperMs = DEFAULT_IDLE_REAPER_MS;
|
|
|
109
93
|
// Reaper sweep cadence. Not exposed as ENGINE_DEFAULTS to keep the pool
|
|
110
94
|
// dependency-free; sub-task C/D can plumb a config knob if needed.
|
|
111
95
|
const REAPER_INTERVAL_MS = 60 * 1000;
|
|
112
|
-
// Cap concurrent warm spawns triggered by tab/modal open. Without a cap, a
|
|
113
|
-
// user spamming new tabs would fan out N parallel cold spawns; with one, the
|
|
114
|
-
// excess waits in FIFO order while the first few warm. The actual user-driven
|
|
115
|
-
// getSession() path is NOT throttled — only the pre-warm path is.
|
|
116
|
-
const WARM_MAX_CONCURRENT = 3;
|
|
117
|
-
|
|
118
|
-
// Test seam — every external side effect goes through `_internals` so
|
|
119
|
-
// test/unit/cc-worker-pool.test.js can stub spawn/now/killImmediate.
|
|
120
|
-
const _internals = {
|
|
121
|
-
spawnAcp({ cwd } = {}) {
|
|
122
|
-
// Resolve `copilot` through the adapter so the pool gets the SAME
|
|
123
|
-
// native/leadingArgs contract as the only other spawn site
|
|
124
|
-
// (engine/spawn-agent.js ~line 595). A bare `spawn('copilot', {shell:false})`
|
|
125
|
-
// cannot exec a `.cmd`/`.ps1`/extensionless npm shim on Windows — it ENOENTs
|
|
126
|
-
// instantly (copilot issue #2370 / W-mqid8uev). resolveBinary() resolves the
|
|
127
|
-
// real JS entry (node_modules/@github/copilot/index.js) and reports whether
|
|
128
|
-
// to spawn it directly (native) or under the current Node process (shim).
|
|
129
|
-
const copilot = require('./runtimes/copilot');
|
|
130
|
-
const resolved = copilot.resolveBinary({ env: process.env });
|
|
131
|
-
if (!resolved || !resolved.bin) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
const { bin, native, leadingArgs = [] } = resolved;
|
|
137
|
-
const acpArgs = [...leadingArgs, '--acp', '--allow-all', '--max-autopilot-continues', '1'];
|
|
138
|
-
// Mirror engine/spawn-agent.js: native binaries run directly; a non-native
|
|
139
|
-
// (JS entry / npm shim) runs under process.execPath as `node <bin> ...`.
|
|
140
|
-
const execBin = native ? bin : process.execPath;
|
|
141
|
-
const execArgs = native ? acpArgs : [bin, ...acpArgs];
|
|
142
|
-
return spawn(execBin, execArgs, {
|
|
143
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
144
|
-
cwd: cwd || process.cwd(),
|
|
145
|
-
windowsHide: true,
|
|
146
|
-
// Don't pass shell:true — the native/leadingArgs handling above already
|
|
147
|
-
// produces a directly-spawnable argv, and a shell wrapper would swallow
|
|
148
|
-
// the stdin/stdout JSON-RPC framing.
|
|
149
|
-
});
|
|
150
|
-
},
|
|
151
|
-
killImmediate(proc) {
|
|
152
|
-
// Lazy require so unit tests that don't load engine/shared still work.
|
|
153
|
-
try {
|
|
154
|
-
const shared = require('./shared');
|
|
155
|
-
shared.killImmediate(proc);
|
|
156
|
-
} catch {
|
|
157
|
-
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
now() { return Date.now(); },
|
|
161
|
-
};
|
|
162
96
|
|
|
163
97
|
const _tabs = new Map();
|
|
164
98
|
let _reaperTimer = null;
|
|
@@ -207,9 +141,7 @@ function consumeIdleReapNotice(tabId) {
|
|
|
207
141
|
// CC_POOL_TRACE-gated structured trace logger. Off by default; enable via
|
|
208
142
|
// `CC_POOL_TRACE=1 minions restart` to dump every getSession lifecycle
|
|
209
143
|
// transition, stream sessionId capture, and session/update notification
|
|
210
|
-
// match/mismatch to stderr.
|
|
211
|
-
// next investigation cycle can correlate engine state with the user-perceived
|
|
212
|
-
// first-message hang. NO PII — only tabId (caller-supplied), sessionIds
|
|
144
|
+
// match/mismatch to stderr. NO PII — only tabId (caller-supplied), sessionIds
|
|
213
145
|
// (opaque ACP ids), and protocol flags. Safe to leave on in dev/staging.
|
|
214
146
|
function _trace(...parts) {
|
|
215
147
|
if (!process.env.CC_POOL_TRACE) return;
|
|
@@ -217,466 +149,6 @@ function _trace(...parts) {
|
|
|
217
149
|
catch { /* swallow telemetry errors */ }
|
|
218
150
|
}
|
|
219
151
|
|
|
220
|
-
function _hashMcpServers(mcpServers) {
|
|
221
|
-
// Stable hash via JSON.stringify; mcpServers is an array of plain objects
|
|
222
|
-
// in practice (name/command/env) so the natural key order is fine.
|
|
223
|
-
const json = mcpServers == null ? '[]' : JSON.stringify(mcpServers);
|
|
224
|
-
return crypto.createHash('sha256').update(json).digest('hex');
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Bug B (issue #2479): `model` and `effort` were stored on the Worker but
|
|
228
|
-
// never forwarded to ACP `session/new`, so any `ccModel` override was silently
|
|
229
|
-
// dropped on the pool path. Build the params object here and only include
|
|
230
|
-
// fields that are defined — sending `model: undefined` would force the daemon
|
|
231
|
-
// onto its built-in default instead of letting it pick whatever the user has
|
|
232
|
-
// configured globally.
|
|
233
|
-
function _buildSessionNewParams({ cwd, mcpServers, model, effort }) {
|
|
234
|
-
const params = { cwd, mcpServers: mcpServers || [] };
|
|
235
|
-
if (model !== undefined && model !== null && model !== '') params.model = model;
|
|
236
|
-
if (effort !== undefined && effort !== null && effort !== '') params.effort = effort;
|
|
237
|
-
return params;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
class Worker {
|
|
241
|
-
constructor({ tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd }) {
|
|
242
|
-
this.tabId = tabId;
|
|
243
|
-
this.model = model;
|
|
244
|
-
this.effort = effort;
|
|
245
|
-
this.mcpServers = mcpServers || [];
|
|
246
|
-
this.mcpServersHash = mcpServersHash;
|
|
247
|
-
this.systemPromptHash = systemPromptHash;
|
|
248
|
-
this.cwd = cwd || process.cwd();
|
|
249
|
-
|
|
250
|
-
this.proc = null;
|
|
251
|
-
this.sessionId = null;
|
|
252
|
-
this.pending = new Map(); // id → { resolve, reject }
|
|
253
|
-
this.inflight = null; // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
|
|
254
|
-
this.readBuf = '';
|
|
255
|
-
this.nextReqId = 1;
|
|
256
|
-
this.lastUsedAt = _internals.now();
|
|
257
|
-
this.killed = false;
|
|
258
|
-
this.spawnError = null;
|
|
259
|
-
this.firstSystemPromptSent = false;
|
|
260
|
-
// In-flight spawn+initialize+session/new promise. Set by getSession()
|
|
261
|
-
// before the worker is registered in _tabs, cleared after the handshake
|
|
262
|
-
// settles. Racing getSession() callers await this to avoid the
|
|
263
|
-
// "warm-reuse path returns sessionId=null while init is still pending"
|
|
264
|
-
// hang on first message of a freshly-warmed tab (W-mpd45blx00072f04).
|
|
265
|
-
//
|
|
266
|
-
// Follow-up investigation (W-mpdavudb000v8446) verified the post-ab141995
|
|
267
|
-
// engine path holds the necessary invariants (see
|
|
268
|
-
// test/unit/cc-worker-pool-fresh-tab-race.test.js):
|
|
269
|
-
// * after `await worker.initPromise`, worker.sessionId is the real id
|
|
270
|
-
// * Worker.stream sets inflight.sessionId to that same real id
|
|
271
|
-
// * session/prompt is written with sessionId === inflight.sessionId
|
|
272
|
-
// When the symptom recurs (intermittent first-message hang despite the
|
|
273
|
-
// fix), it's almost certainly downstream of the pool — SSE delivery,
|
|
274
|
-
// browser-side render, or telemetry overstating delivery. Set
|
|
275
|
-
// `CC_POOL_TRACE=1` to dump every state transition + sessionId snapshot
|
|
276
|
-
// through the pool to stderr so the next investigation can correlate
|
|
277
|
-
// engine state with the user-perceived hang.
|
|
278
|
-
this.initPromise = null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ── Spawn + initialize handshake ────────────────────────────────────────
|
|
282
|
-
async _spawnAndInit() {
|
|
283
|
-
let proc;
|
|
284
|
-
try {
|
|
285
|
-
proc = _internals.spawnAcp({ cwd: this.cwd });
|
|
286
|
-
} catch (err) {
|
|
287
|
-
// spawn() threw synchronously — typically ENOENT (copilot binary not
|
|
288
|
-
// on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
|
|
289
|
-
// can show "install the CLI / fix PATH" guidance.
|
|
290
|
-
throw _typedError(
|
|
291
|
-
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
|
|
292
|
-
ERROR_CODES.WORKER_SPAWN_FAILED,
|
|
293
|
-
true
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
this.proc = proc;
|
|
297
|
-
|
|
298
|
-
proc.stdout.on('data', (chunk) => this._onStdout(chunk));
|
|
299
|
-
// stderr is captured for diagnostics but we don't act on it.
|
|
300
|
-
if (proc.stderr) proc.stderr.on('data', () => { /* ignore */ });
|
|
301
|
-
|
|
302
|
-
// Race the init handshake against an early process exit so an auth
|
|
303
|
-
// failure (which kills the daemon before initialize completes) surfaces
|
|
304
|
-
// a clear error instead of hanging forever.
|
|
305
|
-
let earlyExitReject;
|
|
306
|
-
const earlyExitPromise = new Promise((_, reject) => {
|
|
307
|
-
earlyExitReject = (code) => {
|
|
308
|
-
this.killed = true;
|
|
309
|
-
// Early exit DURING the handshake = acp-handshake-failed (almost
|
|
310
|
-
// always missing `copilot login`, stale CLI, or daemon crash on
|
|
311
|
-
// boot). Retriable so re-auth or a CLI upgrade can recover.
|
|
312
|
-
const err = _typedError(
|
|
313
|
-
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`,
|
|
314
|
-
ERROR_CODES.ACP_HANDSHAKE_FAILED,
|
|
315
|
-
true
|
|
316
|
-
);
|
|
317
|
-
this.spawnError = err;
|
|
318
|
-
this._failAllPending(err);
|
|
319
|
-
reject(err);
|
|
320
|
-
};
|
|
321
|
-
});
|
|
322
|
-
const earlyExitHandler = (code) => earlyExitReject(code);
|
|
323
|
-
proc.once('exit', earlyExitHandler);
|
|
324
|
-
|
|
325
|
-
const errorHandler = (err) => {
|
|
326
|
-
// proc 'error' event fires when the OS can't actually start the child
|
|
327
|
-
// (ENOENT after a successful spawn() call, etc.). Treat as a spawn
|
|
328
|
-
// failure even though we made it past the synchronous spawn() above.
|
|
329
|
-
const wrapped = _typedError(
|
|
330
|
-
`copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
|
|
331
|
-
ERROR_CODES.WORKER_SPAWN_FAILED,
|
|
332
|
-
true
|
|
333
|
-
);
|
|
334
|
-
this.spawnError = wrapped;
|
|
335
|
-
this.killed = true;
|
|
336
|
-
this._failAllPending(wrapped);
|
|
337
|
-
};
|
|
338
|
-
proc.on('error', errorHandler);
|
|
339
|
-
|
|
340
|
-
try {
|
|
341
|
-
await Promise.race([
|
|
342
|
-
this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
|
|
343
|
-
earlyExitPromise,
|
|
344
|
-
]);
|
|
345
|
-
const result = await Promise.race([
|
|
346
|
-
this._call('session/new', _buildSessionNewParams({
|
|
347
|
-
cwd: this.cwd, mcpServers: this.mcpServers, model: this.model, effort: this.effort,
|
|
348
|
-
})),
|
|
349
|
-
earlyExitPromise,
|
|
350
|
-
]);
|
|
351
|
-
this.sessionId = result && result.sessionId;
|
|
352
|
-
if (!this.sessionId) {
|
|
353
|
-
// Handshake completed without an error but the daemon didn't hand
|
|
354
|
-
// back a sessionId — protocol violation or partial init failure.
|
|
355
|
-
throw _typedError(
|
|
356
|
-
'copilot --acp failed -- session/new returned no sessionId',
|
|
357
|
-
ERROR_CODES.ACP_HANDSHAKE_FAILED,
|
|
358
|
-
true
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
} finally {
|
|
362
|
-
// Either the handshake finished (swap to a persistent exit handler that
|
|
363
|
-
// just marks killed) or it failed (proc is dying anyway).
|
|
364
|
-
proc.removeListener('exit', earlyExitHandler);
|
|
365
|
-
}
|
|
366
|
-
proc.on('exit', () => {
|
|
367
|
-
this.killed = true;
|
|
368
|
-
// Post-handshake exit = the daemon died mid-conversation. Retriable
|
|
369
|
-
// because the next call will cold-spawn a fresh worker.
|
|
370
|
-
const err = _typedError(
|
|
371
|
-
'copilot --acp process exited',
|
|
372
|
-
ERROR_CODES.WORKER_DIED,
|
|
373
|
-
true
|
|
374
|
-
);
|
|
375
|
-
this._failAllPending(err);
|
|
376
|
-
// Settle inflight too if it's still hanging
|
|
377
|
-
if (this.inflight && !this.inflight.settled) {
|
|
378
|
-
const cb = this.inflight.onError;
|
|
379
|
-
this.inflight.settled = true;
|
|
380
|
-
this.inflight = null;
|
|
381
|
-
if (cb) try { cb(err); } catch { /* swallow */ }
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
_failAllPending(err) {
|
|
387
|
-
for (const { reject } of this.pending.values()) {
|
|
388
|
-
try { reject(err); } catch { /* swallow */ }
|
|
389
|
-
}
|
|
390
|
-
this.pending.clear();
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// ── Newline-delimited JSON-RPC line framing ────────────────────────────
|
|
394
|
-
_onStdout(chunk) {
|
|
395
|
-
this.readBuf += chunk.toString('utf8');
|
|
396
|
-
let i;
|
|
397
|
-
while ((i = this.readBuf.indexOf('\n')) >= 0) {
|
|
398
|
-
const line = this.readBuf.slice(0, i).trim();
|
|
399
|
-
this.readBuf = this.readBuf.slice(i + 1);
|
|
400
|
-
if (!line) continue;
|
|
401
|
-
let obj;
|
|
402
|
-
try { obj = JSON.parse(line); } catch { continue; }
|
|
403
|
-
this._handleMessage(obj);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
_handleMessage(obj) {
|
|
408
|
-
// Response (matches an outbound id)
|
|
409
|
-
if (obj.id != null && this.pending.has(obj.id)) {
|
|
410
|
-
const { resolve, reject } = this.pending.get(obj.id);
|
|
411
|
-
this.pending.delete(obj.id);
|
|
412
|
-
if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
|
|
413
|
-
else resolve(obj.result);
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
// Notification (no id) — only `session/update` matters for streaming.
|
|
417
|
-
if (obj.method === 'session/update' && obj.params) {
|
|
418
|
-
// Trace EVERY session/update notification, including drops — this is
|
|
419
|
-
// exactly where the W-mpd45blx00072f04 hang manifested (chunks dropped
|
|
420
|
-
// because inflight.sessionId was null). Logging both the notification
|
|
421
|
-
// sid and the inflight sid lets the next investigation cycle prove
|
|
422
|
-
// whether the engine still drops chunks. (W-mpdavudb000v8446)
|
|
423
|
-
const notifSid = obj.params.sessionId;
|
|
424
|
-
const inflightSid = this.inflight ? this.inflight.sessionId : null;
|
|
425
|
-
const updKind = obj.params.update && obj.params.update.sessionUpdate;
|
|
426
|
-
if (!this.inflight) {
|
|
427
|
-
_trace(`tab=${this.tabId} session/update dropped: no inflight (notifSid=${notifSid} kind=${updKind})`);
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
if (notifSid !== inflightSid) {
|
|
431
|
-
_trace(`tab=${this.tabId} session/update dropped: sid mismatch (notifSid=${notifSid} inflightSid=${inflightSid} kind=${updKind})`);
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
_trace(`tab=${this.tabId} session/update delivered: sid=${notifSid} kind=${updKind}`);
|
|
435
|
-
const update = obj.params.update;
|
|
436
|
-
if (!update) return;
|
|
437
|
-
if (update.sessionUpdate === 'agent_message_chunk') {
|
|
438
|
-
const text = _extractChunkText(update.content);
|
|
439
|
-
if (text && this.inflight.onChunk) {
|
|
440
|
-
try { this.inflight.onChunk(text); } catch { /* swallow */ }
|
|
441
|
-
}
|
|
442
|
-
} else if (update.sessionUpdate === 'tool_call' && this.inflight.onToolUse) {
|
|
443
|
-
// ACP `tool_call` (pending) → chip label via _mapAcpToolCallToToolUse.
|
|
444
|
-
const mapped = _mapAcpToolCallToToolUse(update);
|
|
445
|
-
if (mapped) {
|
|
446
|
-
try { this.inflight.onToolUse(mapped.name, mapped.input, update.toolCallId); }
|
|
447
|
-
catch { /* swallow */ }
|
|
448
|
-
}
|
|
449
|
-
} else if (update.sessionUpdate === 'tool_call_update' && this.inflight.onToolUpdate) {
|
|
450
|
-
// Terminal-state updates only (completed/failed). In-progress updates
|
|
451
|
-
// exist in the ACP spec but add chip churn without actionable info.
|
|
452
|
-
if (update.status === 'completed' || update.status === 'failed') {
|
|
453
|
-
try { this.inflight.onToolUpdate(update.toolCallId, update.status); }
|
|
454
|
-
catch { /* swallow */ }
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
_writeFrame(obj) {
|
|
461
|
-
if (this.killed) return;
|
|
462
|
-
if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
|
|
463
|
-
try { this.proc.stdin.write(JSON.stringify(obj) + '\n'); }
|
|
464
|
-
catch { /* pipe may be broken; exit handler will fire */ }
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
_call(method, params) {
|
|
468
|
-
return new Promise((resolve, reject) => {
|
|
469
|
-
const id = this.nextReqId++;
|
|
470
|
-
this.pending.set(id, { resolve, reject });
|
|
471
|
-
this._writeFrame({ jsonrpc: '2.0', id, method, params });
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
_notify(method, params) {
|
|
476
|
-
this._writeFrame({ jsonrpc: '2.0', method, params });
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// ── Stream a single turn ───────────────────────────────────────────────
|
|
480
|
-
stream(promptText, opts = {}) {
|
|
481
|
-
const { onChunk, onToolUse, onToolUpdate, onDone, onError, signal, systemPromptText } = opts;
|
|
482
|
-
if (this.killed) {
|
|
483
|
-
const err = new Error('cc-worker-pool: tab is closed');
|
|
484
|
-
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
485
|
-
return Promise.resolve();
|
|
486
|
-
}
|
|
487
|
-
if (this.inflight) {
|
|
488
|
-
const err = new Error('cc-worker-pool: a prompt is already in flight on this tab');
|
|
489
|
-
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
490
|
-
return Promise.resolve();
|
|
491
|
-
}
|
|
492
|
-
this.lastUsedAt = _internals.now();
|
|
493
|
-
|
|
494
|
-
// Inject the <system> block on the first prompt of a session — Copilot
|
|
495
|
-
// ACP has no in-protocol "set system prompt" method (per Ripley's note),
|
|
496
|
-
// so the per-session adapter pattern of <system>...</system> prepended
|
|
497
|
-
// to the first user message remains correct.
|
|
498
|
-
let prompt = promptText;
|
|
499
|
-
if (!this.firstSystemPromptSent && systemPromptText) {
|
|
500
|
-
prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
|
|
501
|
-
}
|
|
502
|
-
this.firstSystemPromptSent = true;
|
|
503
|
-
|
|
504
|
-
const id = this.nextReqId++;
|
|
505
|
-
const inflight = {
|
|
506
|
-
id,
|
|
507
|
-
sessionId: this.sessionId,
|
|
508
|
-
onChunk,
|
|
509
|
-
onToolUse,
|
|
510
|
-
onToolUpdate,
|
|
511
|
-
onDone,
|
|
512
|
-
onError,
|
|
513
|
-
signal,
|
|
514
|
-
signalHandler: null,
|
|
515
|
-
settled: false,
|
|
516
|
-
};
|
|
517
|
-
this.inflight = inflight;
|
|
518
|
-
// W-mpdavudb000v8446 — trace the sessionId captured by inflight at the
|
|
519
|
-
// exact moment Worker.stream commits to a write. inflight.sessionId is
|
|
520
|
-
// the value session/update notifications must match against in
|
|
521
|
-
// _handleMessage; if it's ever null, every chunk for this turn is silently
|
|
522
|
-
// dropped (the ab141995 hang signature). Pair with the [cc-pool] dispatch
|
|
523
|
-
// log on the dashboard side to correlate engine state with user-perceived
|
|
524
|
-
// delivery.
|
|
525
|
-
_trace(`tab=${this.tabId} stream begin: worker.sessionId=${this.sessionId} inflight.sessionId=${inflight.sessionId} reqId=${id}`);
|
|
526
|
-
|
|
527
|
-
if (signal && typeof signal.addEventListener === 'function') {
|
|
528
|
-
inflight.signalHandler = () => this.cancel();
|
|
529
|
-
try { signal.addEventListener('abort', inflight.signalHandler); } catch { /* swallow */ }
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
return new Promise((resolve) => {
|
|
533
|
-
const finalize = (err, result) => {
|
|
534
|
-
if (inflight.settled) return;
|
|
535
|
-
inflight.settled = true;
|
|
536
|
-
if (this.inflight === inflight) this.inflight = null;
|
|
537
|
-
this.lastUsedAt = _internals.now();
|
|
538
|
-
if (signal && inflight.signalHandler) {
|
|
539
|
-
try { signal.removeEventListener('abort', inflight.signalHandler); }
|
|
540
|
-
catch { /* swallow */ }
|
|
541
|
-
}
|
|
542
|
-
if (err) {
|
|
543
|
-
if (onError) try { onError(err); } catch { /* swallow */ }
|
|
544
|
-
} else {
|
|
545
|
-
if (onDone) try { onDone(result); } catch { /* swallow */ }
|
|
546
|
-
}
|
|
547
|
-
resolve();
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
this.pending.set(id, {
|
|
551
|
-
resolve: (result) => finalize(null, result),
|
|
552
|
-
reject: (err) => finalize(err, null),
|
|
553
|
-
});
|
|
554
|
-
this._writeFrame({
|
|
555
|
-
jsonrpc: '2.0',
|
|
556
|
-
id,
|
|
557
|
-
method: 'session/prompt',
|
|
558
|
-
params: { sessionId: this.sessionId, prompt: [{ type: 'text', text: prompt }] },
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
cancel() {
|
|
564
|
-
if (!this.inflight || this.killed) return;
|
|
565
|
-
// session/cancel is a JSON-RPC notification (no id). The in-flight
|
|
566
|
-
// session/prompt response will arrive with stopReason=cancelled per the
|
|
567
|
-
// ACP spec — we don't synthesize a fake response client-side.
|
|
568
|
-
this._notify('session/cancel', { sessionId: this.inflight.sessionId });
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
async newSession({ mcpServers, systemPromptHash, model, effort }) {
|
|
572
|
-
// Cancel any inflight before swapping the underlying session.
|
|
573
|
-
if (this.inflight) {
|
|
574
|
-
this.cancel();
|
|
575
|
-
// Wait briefly for the cancelled response to settle so we don't leak
|
|
576
|
-
// a stale inflight reference into the new session.
|
|
577
|
-
const inflight = this.inflight;
|
|
578
|
-
await new Promise((resolve) => {
|
|
579
|
-
const start = _internals.now();
|
|
580
|
-
const wait = () => {
|
|
581
|
-
if (!inflight || inflight.settled || _internals.now() - start > 1000) resolve();
|
|
582
|
-
else setTimeout(wait, 5);
|
|
583
|
-
};
|
|
584
|
-
wait();
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
// Bug B (issue #2479): if the caller is rotating the session because the
|
|
588
|
-
// system prompt changed, they may also be passing a fresh model/effort —
|
|
589
|
-
// update bookkeeping BEFORE session/new so the new fields land on the
|
|
590
|
-
// daemon. Falls through to whatever we already had when callers omit them.
|
|
591
|
-
if (model !== undefined) this.model = model;
|
|
592
|
-
if (effort !== undefined) this.effort = effort;
|
|
593
|
-
const result = await this._call('session/new', _buildSessionNewParams({
|
|
594
|
-
cwd: this.cwd, mcpServers: mcpServers || [], model: this.model, effort: this.effort,
|
|
595
|
-
}));
|
|
596
|
-
this.sessionId = result && result.sessionId;
|
|
597
|
-
this.systemPromptHash = systemPromptHash;
|
|
598
|
-
this.firstSystemPromptSent = false;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
close() {
|
|
602
|
-
if (this.killed) return;
|
|
603
|
-
this.killed = true;
|
|
604
|
-
// Best-effort cancel of an inflight prompt so the daemon doesn't keep
|
|
605
|
-
// generating into a torn-down session before we kill the proc.
|
|
606
|
-
if (this.inflight) {
|
|
607
|
-
try { this._notify('session/cancel', { sessionId: this.inflight.sessionId }); }
|
|
608
|
-
catch { /* swallow */ }
|
|
609
|
-
}
|
|
610
|
-
if (this.proc) {
|
|
611
|
-
try { _internals.killImmediate(this.proc); } catch { /* already dead */ }
|
|
612
|
-
}
|
|
613
|
-
this._failAllPending(new Error('cc-worker-pool: worker closed'));
|
|
614
|
-
if (this.inflight && !this.inflight.settled) {
|
|
615
|
-
const cb = this.inflight.onError;
|
|
616
|
-
this.inflight.settled = true;
|
|
617
|
-
this.inflight = null;
|
|
618
|
-
if (cb) try { cb(new Error('cc-worker-pool: worker closed')); } catch { /* swallow */ }
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function _extractChunkText(content) {
|
|
624
|
-
if (content == null) return '';
|
|
625
|
-
if (typeof content === 'string') return content;
|
|
626
|
-
if (Array.isArray(content)) {
|
|
627
|
-
let out = '';
|
|
628
|
-
for (const block of content) {
|
|
629
|
-
if (block && typeof block.text === 'string') out += block.text;
|
|
630
|
-
}
|
|
631
|
-
return out;
|
|
632
|
-
}
|
|
633
|
-
if (typeof content === 'object' && typeof content.text === 'string') return content.text;
|
|
634
|
-
return '';
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// Map an ACP `tool_call` session/update notification to the {name, input} shape
|
|
638
|
-
// the dashboard's formatToolSummary already understands. ACP's `kind` is a
|
|
639
|
-
// coarse category (execute|read|edit|search|fetch|think|other); we translate to
|
|
640
|
-
// the closest Claude tool name so the existing chip formatters keep working
|
|
641
|
-
// (Bash → "$ <cmd>", Read → "Reading <path>", etc.). Unknown kinds fall back
|
|
642
|
-
// to ACP's human-readable `title` with the raw input attached, which renders
|
|
643
|
-
// through the default `<title>(<key>: <val>)` formatter.
|
|
644
|
-
// Map an ACP `tool_call` notification to a chip label. Strategy:
|
|
645
|
-
//
|
|
646
|
-
// 1. Detect well-known shapes by rawInput field presence and route through
|
|
647
|
-
// formatToolSummary's Claude-tool formatters (Bash → "$ <cmd>",
|
|
648
|
-
// Grep → "Searching `pat` in path", Read → "Reading <path>"). Preserves
|
|
649
|
-
// the granular detail you actually want when reading the chip list.
|
|
650
|
-
//
|
|
651
|
-
// 2. Fall back to ACP's human-curated `title` for anything that doesn't
|
|
652
|
-
// match. Title is always populated and immune to field-name drift, so
|
|
653
|
-
// the empty-`$` failure mode (kind:execute with no `command`) becomes
|
|
654
|
-
// a clean "Find work item W-..." chip instead of a broken stub.
|
|
655
|
-
//
|
|
656
|
-
// Kind-based routing is intentionally NOT used — ACP overloads `kind:read`
|
|
657
|
-
// for both file-view and grep, and `kind:execute` sometimes arrives without
|
|
658
|
-
// a `command`. Field-detection on rawInput is more reliable.
|
|
659
|
-
function _mapAcpToolCallToToolUse(update) {
|
|
660
|
-
if (!update || update.sessionUpdate !== 'tool_call') return null;
|
|
661
|
-
const rawInput = (update.rawInput && typeof update.rawInput === 'object') ? update.rawInput : {};
|
|
662
|
-
const title = update.title || update.kind || 'Tool';
|
|
663
|
-
|
|
664
|
-
if (typeof rawInput.command === 'string' && rawInput.command) {
|
|
665
|
-
return { name: 'Bash', input: { command: rawInput.command } };
|
|
666
|
-
}
|
|
667
|
-
if (typeof rawInput.pattern === 'string' && rawInput.pattern) {
|
|
668
|
-
return { name: 'Grep', input: { pattern: rawInput.pattern, path: rawInput.paths || rawInput.path || '.' } };
|
|
669
|
-
}
|
|
670
|
-
if (typeof rawInput.path === 'string' && rawInput.path) {
|
|
671
|
-
const isEdit = String(update.kind || '').toLowerCase() === 'edit';
|
|
672
|
-
return { name: isEdit ? 'Edit' : 'Read', input: { file_path: rawInput.path } };
|
|
673
|
-
}
|
|
674
|
-
if (typeof rawInput.url === 'string' && rawInput.url) {
|
|
675
|
-
return { name: 'WebFetch', input: { url: rawInput.url } };
|
|
676
|
-
}
|
|
677
|
-
return { name: title, input: {} };
|
|
678
|
-
}
|
|
679
|
-
|
|
680
152
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
681
153
|
|
|
682
154
|
async function getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
|
|
@@ -750,7 +222,8 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
|
|
|
750
222
|
|
|
751
223
|
if (!worker) {
|
|
752
224
|
worker = new Worker({
|
|
753
|
-
tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
|
|
225
|
+
id: tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
|
|
226
|
+
internals: _internals, trace: _trace,
|
|
754
227
|
});
|
|
755
228
|
_tabs.set(tabId, worker);
|
|
756
229
|
// Set initPromise BEFORE awaiting so concurrent getSession() callers
|
|
@@ -923,7 +396,9 @@ module.exports = {
|
|
|
923
396
|
// W-mpmwxni2000c25c7-c — typed-error envelope contract. Exported so the
|
|
924
397
|
// dashboard pool wrappers (and their tests) reference the same string
|
|
925
398
|
// constants and so the doc-chat timeout path can stamp the same
|
|
926
|
-
// `{ message, code, retriable }` shape the pool itself emits.
|
|
399
|
+
// `{ message, code, retriable }` shape the pool itself emits. Re-exported
|
|
400
|
+
// from engine/acp-transport.js (P-4c6e8a72) — this pool no longer defines
|
|
401
|
+
// its own copy.
|
|
927
402
|
ERROR_CODES,
|
|
928
403
|
_typedError,
|
|
929
404
|
};
|