@borgee/agents-host 0.2.33 → 0.2.35
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/README.md +28 -7
- package/dist/agents-host.d.ts +17 -0
- package/dist/agents-host.js +52 -0
- package/dist/chat/chat-control-plane.d.ts +9 -0
- package/dist/chat/sdk-chat-control-plane.d.ts +10 -1
- package/dist/chat/sdk-chat-control-plane.js +6 -0
- package/dist/cli-args.d.ts +1 -1
- package/dist/cli-args.js +5 -0
- package/dist/compatibility-gates.d.ts +1 -0
- package/dist/compatibility-gates.js +2 -0
- package/dist/config.d.ts +4 -1
- package/dist/config.js +21 -3
- package/dist/context/main-session-delegation.d.ts +1 -0
- package/dist/context/main-session-delegation.js +6 -0
- package/dist/context/prompt.js +4 -2
- package/dist/gateway/localhost-gateway.js +66 -0
- package/dist/local-config.js +10 -1
- package/dist/managed-daemon.js +5 -0
- package/dist/plugin-sdk.js +58 -1
- package/dist/plugin-sdk.js.map +2 -2
- package/dist/policy/gateway-authorization.d.ts +18 -3
- package/dist/policy/gateway-authorization.js +33 -1
- package/dist/providers/claude/adapter.js +3 -1
- package/dist/providers/claude/cli-client.d.ts +25 -1
- package/dist/providers/claude/cli-client.js +127 -10
- package/dist/providers/codex/adapter.js +3 -1
- package/dist/providers/codex/cli-client.d.ts +25 -1
- package/dist/providers/codex/cli-client.js +127 -10
- package/dist/providers/codex/project-doc.js +3 -0
- package/dist/providers/copilot/adapter.js +3 -1
- package/dist/providers/copilot/cli-client.d.ts +25 -1
- package/dist/providers/copilot/cli-client.js +125 -10
- package/dist/providers/create-provider.js +14 -9
- package/dist/providers/idle-backend-shutdown.d.ts +16 -0
- package/dist/providers/idle-backend-shutdown.js +53 -0
- package/dist/types.d.ts +7 -0
- package/package.json +2 -2
- package/skills/borgee-agent/SKILL.md +19 -2
- package/skills/borgee-agent/borgee-agent.mjs +56 -1
- package/skills/borgee-agent/borgee-agent.py +33 -2
|
@@ -6,8 +6,18 @@ import spawn from 'cross-spawn';
|
|
|
6
6
|
import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
|
|
7
7
|
import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
|
|
8
8
|
import { assertCodexProjectDocumentSize, buildCodexProjectDocument } from './project-doc.js';
|
|
9
|
+
import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
|
|
9
10
|
const SESSION_TAINTED_ERRORS = new WeakSet();
|
|
10
11
|
const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
|
|
12
|
+
const IDLE_BACKEND_STOPPED_MESSAGE = 'Codex ACP backend stopped after idle timeout';
|
|
13
|
+
/**
|
|
14
|
+
* A fatal teardown has callers waiting on it, so it force-kills quickly. An
|
|
15
|
+
* idle recycle has nobody waiting, and the CLIs unlink their on-disk session
|
|
16
|
+
* locks during SIGTERM cleanup — a lock left behind by SIGKILL makes the next
|
|
17
|
+
* restore believe the session is still in use (github/copilot-cli#3255). So the
|
|
18
|
+
* idle path trades latency nobody observes for a clean exit.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
|
|
11
21
|
const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
|
|
12
22
|
const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
|
|
13
23
|
const DEFAULT_SESSION_CAPABILITIES = {
|
|
@@ -30,6 +40,8 @@ const DEFAULT_RUNTIME = {
|
|
|
30
40
|
protocolVersion: PROTOCOL_VERSION,
|
|
31
41
|
cwd: process.cwd(),
|
|
32
42
|
idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
|
|
43
|
+
idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
|
|
44
|
+
idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
|
|
33
45
|
shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
|
|
34
46
|
shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
|
|
35
47
|
readFile: async (path, options) => {
|
|
@@ -286,6 +298,14 @@ export class CodexCliClient {
|
|
|
286
298
|
logger;
|
|
287
299
|
runtime;
|
|
288
300
|
channels = new Map();
|
|
301
|
+
/**
|
|
302
|
+
* The provider session currently bound to channelId, or undefined before a
|
|
303
|
+
* turn has opened one. Surfaced so agents-host can record which session
|
|
304
|
+
* worked a task without the agent being asked to report its own id.
|
|
305
|
+
*/
|
|
306
|
+
sessionIdForChannel(channelId) {
|
|
307
|
+
return this.channels.get(channelId)?.session?.sessionId;
|
|
308
|
+
}
|
|
289
309
|
persistedSessions = new Map();
|
|
290
310
|
closingSessions = new WeakSet();
|
|
291
311
|
pendingSessionStarts = new Set();
|
|
@@ -298,7 +318,13 @@ export class CodexCliClient {
|
|
|
298
318
|
startPromise;
|
|
299
319
|
shutdownPromise;
|
|
300
320
|
childExitPromise;
|
|
301
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Bumped on every spawn so the `exit` / `connection.closed` handlers of a
|
|
323
|
+
* backend we deliberately recycled stay silent instead of latching a fatal
|
|
324
|
+
* error onto the client that already owns its successor.
|
|
325
|
+
*/
|
|
326
|
+
backendGeneration = 0;
|
|
327
|
+
idleShutdownPromise;
|
|
302
328
|
fatalError = null;
|
|
303
329
|
disposing = false;
|
|
304
330
|
backendClosed = false;
|
|
@@ -306,6 +332,7 @@ export class CodexCliClient {
|
|
|
306
332
|
sessionStoreLoadPromise = null;
|
|
307
333
|
sessionStoreWriteQueue = Promise.resolve();
|
|
308
334
|
sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
335
|
+
idleBackendShutdown;
|
|
309
336
|
constructor(command, args = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger()) {
|
|
310
337
|
this.command = command;
|
|
311
338
|
this.args = args;
|
|
@@ -313,6 +340,16 @@ export class CodexCliClient {
|
|
|
313
340
|
this.resolveSessionStoreAgentId = resolveSessionStoreAgentId;
|
|
314
341
|
this.logger = logger;
|
|
315
342
|
this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
|
|
343
|
+
this.idleBackendShutdown = new IdleBackendShutdownScheduler({
|
|
344
|
+
idleShutdownMs: this.runtime.idleBackendShutdownMs,
|
|
345
|
+
isIdle: () => this.isBackendIdle(),
|
|
346
|
+
shutdown: () => this.shutdownIdleBackend(),
|
|
347
|
+
onShutdownFailed: (error) => {
|
|
348
|
+
this.logger.error('failed to stop the idle Codex ACP backend', {
|
|
349
|
+
error: summarizeError(error),
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
});
|
|
316
353
|
this.fatalPromise = new Promise((_, reject) => {
|
|
317
354
|
this.rejectFatalPromise = reject;
|
|
318
355
|
});
|
|
@@ -334,6 +371,7 @@ export class CodexCliClient {
|
|
|
334
371
|
const sessionRouting = resolveSessionRouting(preparedTurn);
|
|
335
372
|
const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
|
|
336
373
|
this.clearIdleTimer(state);
|
|
374
|
+
this.idleBackendShutdown.cancel();
|
|
337
375
|
const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, preparedTurn.promptContext, sessionRouting.persistence, options);
|
|
338
376
|
state.queue.push(turn);
|
|
339
377
|
this.processChannelQueue(sessionRouting.key, state);
|
|
@@ -342,12 +380,17 @@ export class CodexCliClient {
|
|
|
342
380
|
async dispose() {
|
|
343
381
|
const error = new Error('Codex ACP backend stopped');
|
|
344
382
|
this.disposing = true;
|
|
383
|
+
this.idleBackendShutdown.cancel();
|
|
345
384
|
this.logger?.debug('stopping Codex ACP backend');
|
|
346
385
|
const closed = this.connection?.closed ?? Promise.resolve();
|
|
347
386
|
this.failAll(error);
|
|
348
387
|
let thrown;
|
|
349
388
|
try {
|
|
350
|
-
await Promise.all([
|
|
389
|
+
await Promise.all([
|
|
390
|
+
closed,
|
|
391
|
+
this.idleShutdownPromise ?? Promise.resolve(),
|
|
392
|
+
this.shutdownPromise ?? Promise.resolve(),
|
|
393
|
+
]);
|
|
351
394
|
}
|
|
352
395
|
catch (disposeError) {
|
|
353
396
|
thrown = disposeError;
|
|
@@ -368,6 +411,12 @@ export class CodexCliClient {
|
|
|
368
411
|
if (this.fatalError) {
|
|
369
412
|
throw this.fatalError;
|
|
370
413
|
}
|
|
414
|
+
if (this.idleShutdownPromise) {
|
|
415
|
+
await this.idleShutdownPromise;
|
|
416
|
+
if (this.fatalError) {
|
|
417
|
+
throw this.fatalError;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
371
420
|
if (!this.startPromise) {
|
|
372
421
|
this.startPromise = this.startBackend();
|
|
373
422
|
}
|
|
@@ -380,6 +429,8 @@ export class CodexCliClient {
|
|
|
380
429
|
}
|
|
381
430
|
}
|
|
382
431
|
async startBackend() {
|
|
432
|
+
const generation = ++this.backendGeneration;
|
|
433
|
+
const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
|
|
383
434
|
const launch = resolveAdapterLaunch(this.command, this.args);
|
|
384
435
|
this.logger?.debug('starting Codex ACP process', {
|
|
385
436
|
command: launch.command,
|
|
@@ -396,13 +447,20 @@ export class CodexCliClient {
|
|
|
396
447
|
});
|
|
397
448
|
child.stderr.resume();
|
|
398
449
|
this.logger?.debug('spawned Codex ACP process', { pid: child.pid });
|
|
450
|
+
let resolveChildExit;
|
|
451
|
+
this.childExitPromise = new Promise((resolve) => {
|
|
452
|
+
resolveChildExit = resolve;
|
|
453
|
+
});
|
|
399
454
|
child.once('error', (error) => {
|
|
400
455
|
this.logger?.debugError('Codex ACP process error', error);
|
|
456
|
+
if (!ownsCurrentBackend()) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
401
459
|
this.failAll(new Error(`Codex ACP process error: ${normalizeError(error).message}`));
|
|
402
460
|
});
|
|
403
461
|
child.once('exit', (code, signal) => {
|
|
404
|
-
|
|
405
|
-
if (
|
|
462
|
+
resolveChildExit();
|
|
463
|
+
if (!ownsCurrentBackend()) {
|
|
406
464
|
this.logger?.debug('Codex ACP process exited during shutdown', { code, signal });
|
|
407
465
|
return;
|
|
408
466
|
}
|
|
@@ -410,9 +468,6 @@ export class CodexCliClient {
|
|
|
410
468
|
const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
|
|
411
469
|
this.failAll(new Error(`Codex ACP process exited unexpectedly (${suffix})`));
|
|
412
470
|
});
|
|
413
|
-
this.childExitPromise = new Promise((resolveChildExit) => {
|
|
414
|
-
this.resolveChildExit = resolveChildExit;
|
|
415
|
-
});
|
|
416
471
|
const output = Writable.toWeb(child.stdin);
|
|
417
472
|
const input = Readable.toWeb(child.stdout);
|
|
418
473
|
const stream = this.runtime.ndJsonStream(output, input);
|
|
@@ -422,7 +477,7 @@ export class CodexCliClient {
|
|
|
422
477
|
const connection = app.connect(stream);
|
|
423
478
|
this.connection = connection;
|
|
424
479
|
void connection.closed.then(() => {
|
|
425
|
-
if (
|
|
480
|
+
if (ownsCurrentBackend()) {
|
|
426
481
|
this.logger?.debugError('Codex ACP connection closed unexpectedly');
|
|
427
482
|
this.failAll(new Error('Codex ACP connection closed unexpectedly'));
|
|
428
483
|
}
|
|
@@ -512,6 +567,7 @@ export class CodexCliClient {
|
|
|
512
567
|
finally {
|
|
513
568
|
state.processing = false;
|
|
514
569
|
this.reconcileIdleChannelState(channelId, state);
|
|
570
|
+
this.idleBackendShutdown.reconcile();
|
|
515
571
|
}
|
|
516
572
|
})();
|
|
517
573
|
}
|
|
@@ -856,6 +912,7 @@ export class CodexCliClient {
|
|
|
856
912
|
}
|
|
857
913
|
this.fatalError = error;
|
|
858
914
|
this.rejectFatalPromise(error);
|
|
915
|
+
this.idleBackendShutdown.cancel();
|
|
859
916
|
for (const [channelId, state] of this.channels.entries()) {
|
|
860
917
|
this.clearIdleTimer(state);
|
|
861
918
|
state.activeTurn?.reject(error);
|
|
@@ -1073,6 +1130,66 @@ export class CodexCliClient {
|
|
|
1073
1130
|
return;
|
|
1074
1131
|
}
|
|
1075
1132
|
this.backendClosed = true;
|
|
1133
|
+
await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Backend teardown without the fatal latch: the client stays usable and
|
|
1137
|
+
* `ensureStarted()` spawns a replacement process on the next turn. Only safe
|
|
1138
|
+
* while `isBackendIdle()` holds, because every channel's live ACP session is
|
|
1139
|
+
* dropped here and no turn is ever replayed onto its successor.
|
|
1140
|
+
*/
|
|
1141
|
+
async shutdownIdleBackend() {
|
|
1142
|
+
// Detach the running backend before any signal reaches it, so its own exit
|
|
1143
|
+
// and connection-closed handlers read this teardown as deliberate instead
|
|
1144
|
+
// of latching a fatal error on a client that stays usable.
|
|
1145
|
+
this.backendGeneration += 1;
|
|
1146
|
+
const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
|
|
1147
|
+
const sessions = [];
|
|
1148
|
+
for (const [channelId, state] of [...this.channels.entries()]) {
|
|
1149
|
+
this.clearIdleTimer(state);
|
|
1150
|
+
if (state.session) {
|
|
1151
|
+
sessions.push(state.session);
|
|
1152
|
+
}
|
|
1153
|
+
state.session = undefined;
|
|
1154
|
+
state.sessionCwd = undefined;
|
|
1155
|
+
state.sessionAdditionalDirectories = undefined;
|
|
1156
|
+
state.sessionVisibilityKey = undefined;
|
|
1157
|
+
this.channels.delete(channelId);
|
|
1158
|
+
}
|
|
1159
|
+
this.logger?.debug('stopping idle Codex ACP backend', {
|
|
1160
|
+
channels: sessions.length,
|
|
1161
|
+
idleShutdownMs: this.runtime.idleBackendShutdownMs,
|
|
1162
|
+
});
|
|
1163
|
+
for (const session of sessions) {
|
|
1164
|
+
this.closeSession(session);
|
|
1165
|
+
}
|
|
1166
|
+
const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
|
|
1167
|
+
this.startPromise = undefined;
|
|
1168
|
+
this.childExitPromise = undefined;
|
|
1169
|
+
this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
1170
|
+
this.idleShutdownPromise = undefined;
|
|
1171
|
+
});
|
|
1172
|
+
this.idleShutdownPromise = idleShutdownPromise;
|
|
1173
|
+
await idleShutdownPromise;
|
|
1174
|
+
}
|
|
1175
|
+
isBackendIdle() {
|
|
1176
|
+
if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
if (!this.child) {
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
if (this.pendingSessionStarts.size > 0) {
|
|
1183
|
+
return false;
|
|
1184
|
+
}
|
|
1185
|
+
for (const state of this.channels.values()) {
|
|
1186
|
+
if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return true;
|
|
1191
|
+
}
|
|
1192
|
+
async closeBackendProcess(error, gracePeriodMs) {
|
|
1076
1193
|
const connection = this.connection;
|
|
1077
1194
|
const child = this.child;
|
|
1078
1195
|
await this.waitForPendingSessionStarts();
|
|
@@ -1084,9 +1201,9 @@ export class CodexCliClient {
|
|
|
1084
1201
|
return;
|
|
1085
1202
|
}
|
|
1086
1203
|
const childExitPromise = this.childExitPromise ?? Promise.resolve();
|
|
1087
|
-
this.logger?.debug('sending SIGTERM to Codex ACP process');
|
|
1204
|
+
this.logger?.debug('sending SIGTERM to Codex ACP process', { gracePeriodMs });
|
|
1088
1205
|
child.kill('SIGTERM');
|
|
1089
|
-
const exitedAfterTerm = await this.waitForChildExit(childExitPromise,
|
|
1206
|
+
const exitedAfterTerm = await this.waitForChildExit(childExitPromise, gracePeriodMs);
|
|
1090
1207
|
if (exitedAfterTerm) {
|
|
1091
1208
|
this.logger?.debug('Codex ACP process exited after SIGTERM');
|
|
1092
1209
|
this.child = undefined;
|
|
@@ -2,6 +2,7 @@ import { basename } from 'node:path';
|
|
|
2
2
|
import { buildAttentionSummaryLines } from '../../context/attention.js';
|
|
3
3
|
import { buildCollaborationCapabilityDeclarationSummaryLines, buildMissedCollaborationDiagnosticSummaryLines, } from '../../context/collaboration-capabilities-diagnostics.js';
|
|
4
4
|
import { buildCollaborationOutcomeSummaryLines } from '../../context/collaboration-outcome.js';
|
|
5
|
+
import { MAIN_SESSION_DELEGATION_LINES } from '../../context/main-session-delegation.js';
|
|
5
6
|
import { buildTaskThreadCollaborationSummaryLines } from '../../context/task-thread-collaboration.js';
|
|
6
7
|
const PROJECT_DOC_MAX_BYTES = 32 * 1024;
|
|
7
8
|
function buildSkillRuntimeLines(context) {
|
|
@@ -37,6 +38,8 @@ export function buildCodexProjectDocument(context) {
|
|
|
37
38
|
'',
|
|
38
39
|
'This workspace belongs to one Borgee channel session hosted by agents-host.',
|
|
39
40
|
'Keep visible replies concise, do not claim actions you did not perform, and prefer the per-turn prompt when it provides newer turn-local details.',
|
|
41
|
+
'',
|
|
42
|
+
...MAIN_SESSION_DELEGATION_LINES,
|
|
40
43
|
...(context?.channelContextPayloadPath
|
|
41
44
|
? [
|
|
42
45
|
'',
|
|
@@ -11,7 +11,9 @@ export class CopilotProviderAdapter {
|
|
|
11
11
|
const text = await this.cli.generateReply(preparedTurn, {
|
|
12
12
|
onProgress: createAwaitingUserProgressHandler(options?.onProgress),
|
|
13
13
|
});
|
|
14
|
-
|
|
14
|
+
// Read the session AFTER the turn: a first turn on a channel opens the
|
|
15
|
+
// session as it runs, so before this point there is nothing to report.
|
|
16
|
+
return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
|
|
15
17
|
}
|
|
16
18
|
async dispose() {
|
|
17
19
|
await this.cli.dispose();
|
|
@@ -13,6 +13,8 @@ interface CopilotAcpRuntime {
|
|
|
13
13
|
protocolVersion: typeof PROTOCOL_VERSION;
|
|
14
14
|
cwd: string;
|
|
15
15
|
idleSessionTtlMs: number;
|
|
16
|
+
idleBackendShutdownMs: number;
|
|
17
|
+
idleShutdownGracePeriodMs: number;
|
|
16
18
|
shutdownGracePeriodMs: number;
|
|
17
19
|
shutdownForceKillWaitMs: number;
|
|
18
20
|
}
|
|
@@ -37,6 +39,12 @@ export declare class CopilotCliClient {
|
|
|
37
39
|
private readonly permissionPolicy;
|
|
38
40
|
private readonly runtime;
|
|
39
41
|
private readonly channels;
|
|
42
|
+
/**
|
|
43
|
+
* The provider session currently bound to channelId, or undefined before a
|
|
44
|
+
* turn has opened one. Surfaced so agents-host can record which session
|
|
45
|
+
* worked a task without the agent being asked to report its own id.
|
|
46
|
+
*/
|
|
47
|
+
sessionIdForChannel(channelId: string): string | undefined;
|
|
40
48
|
private readonly persistedSessions;
|
|
41
49
|
private readonly closingSessions;
|
|
42
50
|
private readonly pendingSessionStarts;
|
|
@@ -49,7 +57,13 @@ export declare class CopilotCliClient {
|
|
|
49
57
|
private startPromise?;
|
|
50
58
|
private shutdownPromise?;
|
|
51
59
|
private childExitPromise?;
|
|
52
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Bumped on every spawn so the `exit` / `connection.closed` handlers of a
|
|
62
|
+
* backend we deliberately recycled stay silent instead of latching a fatal
|
|
63
|
+
* error onto the client that already owns its successor.
|
|
64
|
+
*/
|
|
65
|
+
private backendGeneration;
|
|
66
|
+
private idleShutdownPromise?;
|
|
53
67
|
private fatalError;
|
|
54
68
|
private disposing;
|
|
55
69
|
private backendClosed;
|
|
@@ -57,6 +71,7 @@ export declare class CopilotCliClient {
|
|
|
57
71
|
private sessionStoreLoadPromise;
|
|
58
72
|
private sessionStoreWriteQueue;
|
|
59
73
|
private sessionCapabilities;
|
|
74
|
+
private readonly idleBackendShutdown;
|
|
60
75
|
constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>, sessionStore?: CopilotChannelSessionStore | undefined, resolveSessionStoreAgentId?: () => string | undefined, logger?: DebugLogger, permissionPolicy?: CopilotPermissionPolicyOptions);
|
|
61
76
|
generateReply(turn: PreparedProviderTurnInput, options?: ProviderGenerateOptions): Promise<string>;
|
|
62
77
|
generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
|
|
@@ -92,6 +107,15 @@ export declare class CopilotCliClient {
|
|
|
92
107
|
private trackSessionStoreOperation;
|
|
93
108
|
private evictIdleChannel;
|
|
94
109
|
private shutdownBackend;
|
|
110
|
+
/**
|
|
111
|
+
* Backend teardown without the fatal latch: the client stays usable and
|
|
112
|
+
* `ensureStarted()` spawns a replacement process on the next turn. Only safe
|
|
113
|
+
* while `isBackendIdle()` holds, because every channel's live ACP session is
|
|
114
|
+
* dropped here and no turn is ever replayed onto its successor.
|
|
115
|
+
*/
|
|
116
|
+
private shutdownIdleBackend;
|
|
117
|
+
private isBackendIdle;
|
|
118
|
+
private closeBackendProcess;
|
|
95
119
|
private waitForPendingSessionStarts;
|
|
96
120
|
private waitForPendingSessionCloses;
|
|
97
121
|
private waitForChildExit;
|
|
@@ -3,8 +3,18 @@ import spawn from 'cross-spawn';
|
|
|
3
3
|
import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
|
|
4
4
|
import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
|
|
5
5
|
import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
|
|
6
|
+
import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
|
|
6
7
|
const SESSION_TAINTED_ERRORS = new WeakSet();
|
|
7
8
|
const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const IDLE_BACKEND_STOPPED_MESSAGE = 'Copilot ACP backend stopped after idle timeout';
|
|
10
|
+
/**
|
|
11
|
+
* A fatal teardown has callers waiting on it, so it force-kills quickly. An
|
|
12
|
+
* idle recycle has nobody waiting, and the CLIs unlink their on-disk session
|
|
13
|
+
* locks during SIGTERM cleanup — a lock left behind by SIGKILL makes the next
|
|
14
|
+
* restore believe the session is still in use (github/copilot-cli#3255). So the
|
|
15
|
+
* idle path trades latency nobody observes for a clean exit.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
|
|
8
18
|
const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
|
|
9
19
|
const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
|
|
10
20
|
const QUEUED_TURN_DROPPED_MESSAGE = 'Copilot ACP session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
|
|
@@ -16,6 +26,8 @@ const DEFAULT_RUNTIME = {
|
|
|
16
26
|
protocolVersion: PROTOCOL_VERSION,
|
|
17
27
|
cwd: process.cwd(),
|
|
18
28
|
idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
|
|
29
|
+
idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
|
|
30
|
+
idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
|
|
19
31
|
shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
|
|
20
32
|
shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
|
|
21
33
|
};
|
|
@@ -206,6 +218,14 @@ export class CopilotCliClient {
|
|
|
206
218
|
permissionPolicy;
|
|
207
219
|
runtime;
|
|
208
220
|
channels = new Map();
|
|
221
|
+
/**
|
|
222
|
+
* The provider session currently bound to channelId, or undefined before a
|
|
223
|
+
* turn has opened one. Surfaced so agents-host can record which session
|
|
224
|
+
* worked a task without the agent being asked to report its own id.
|
|
225
|
+
*/
|
|
226
|
+
sessionIdForChannel(channelId) {
|
|
227
|
+
return this.channels.get(channelId)?.session?.sessionId;
|
|
228
|
+
}
|
|
209
229
|
persistedSessions = new Map();
|
|
210
230
|
closingSessions = new WeakSet();
|
|
211
231
|
pendingSessionStarts = new Set();
|
|
@@ -218,7 +238,13 @@ export class CopilotCliClient {
|
|
|
218
238
|
startPromise;
|
|
219
239
|
shutdownPromise;
|
|
220
240
|
childExitPromise;
|
|
221
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Bumped on every spawn so the `exit` / `connection.closed` handlers of a
|
|
243
|
+
* backend we deliberately recycled stay silent instead of latching a fatal
|
|
244
|
+
* error onto the client that already owns its successor.
|
|
245
|
+
*/
|
|
246
|
+
backendGeneration = 0;
|
|
247
|
+
idleShutdownPromise;
|
|
222
248
|
fatalError = null;
|
|
223
249
|
disposing = false;
|
|
224
250
|
backendClosed = false;
|
|
@@ -226,6 +252,7 @@ export class CopilotCliClient {
|
|
|
226
252
|
sessionStoreLoadPromise = null;
|
|
227
253
|
sessionStoreWriteQueue = Promise.resolve();
|
|
228
254
|
sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
255
|
+
idleBackendShutdown;
|
|
229
256
|
constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), permissionPolicy = {}) {
|
|
230
257
|
this.command = command;
|
|
231
258
|
this.sessionStore = sessionStore;
|
|
@@ -233,6 +260,16 @@ export class CopilotCliClient {
|
|
|
233
260
|
this.logger = logger;
|
|
234
261
|
this.permissionPolicy = permissionPolicy;
|
|
235
262
|
this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
|
|
263
|
+
this.idleBackendShutdown = new IdleBackendShutdownScheduler({
|
|
264
|
+
idleShutdownMs: this.runtime.idleBackendShutdownMs,
|
|
265
|
+
isIdle: () => this.isBackendIdle(),
|
|
266
|
+
shutdown: () => this.shutdownIdleBackend(),
|
|
267
|
+
onShutdownFailed: (error) => {
|
|
268
|
+
this.logger.error('failed to stop the idle Copilot ACP backend', {
|
|
269
|
+
error: summarizeError(error),
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
});
|
|
236
273
|
this.fatalPromise = new Promise((_, reject) => {
|
|
237
274
|
this.rejectFatalPromise = reject;
|
|
238
275
|
});
|
|
@@ -254,6 +291,7 @@ export class CopilotCliClient {
|
|
|
254
291
|
const sessionRouting = resolveSessionRouting(preparedTurn);
|
|
255
292
|
const state = this.getOrCreateChannelState(sessionRouting.key, preparedTurn.channelId, sessionRouting.persistence);
|
|
256
293
|
this.clearIdleTimer(state);
|
|
294
|
+
this.idleBackendShutdown.cancel();
|
|
257
295
|
const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn.prompt, sessionRouting.persistence, preparedTurn.promptContext, options);
|
|
258
296
|
state.queue.push(turn);
|
|
259
297
|
this.processChannelQueue(sessionRouting.key, state);
|
|
@@ -262,12 +300,17 @@ export class CopilotCliClient {
|
|
|
262
300
|
async dispose() {
|
|
263
301
|
const error = new Error('Copilot ACP backend stopped');
|
|
264
302
|
this.disposing = true;
|
|
303
|
+
this.idleBackendShutdown.cancel();
|
|
265
304
|
this.logger?.debug('stopping Copilot ACP backend');
|
|
266
305
|
const closed = this.connection?.closed ?? Promise.resolve();
|
|
267
306
|
this.failAll(error);
|
|
268
307
|
let thrown;
|
|
269
308
|
try {
|
|
270
|
-
await Promise.all([
|
|
309
|
+
await Promise.all([
|
|
310
|
+
closed,
|
|
311
|
+
this.idleShutdownPromise ?? Promise.resolve(),
|
|
312
|
+
this.shutdownPromise ?? Promise.resolve(),
|
|
313
|
+
]);
|
|
271
314
|
}
|
|
272
315
|
catch (disposeError) {
|
|
273
316
|
thrown = disposeError;
|
|
@@ -288,6 +331,12 @@ export class CopilotCliClient {
|
|
|
288
331
|
if (this.fatalError) {
|
|
289
332
|
throw this.fatalError;
|
|
290
333
|
}
|
|
334
|
+
if (this.idleShutdownPromise) {
|
|
335
|
+
await this.idleShutdownPromise;
|
|
336
|
+
if (this.fatalError) {
|
|
337
|
+
throw this.fatalError;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
291
340
|
if (!this.startPromise) {
|
|
292
341
|
this.startPromise = this.startBackend();
|
|
293
342
|
}
|
|
@@ -300,6 +349,8 @@ export class CopilotCliClient {
|
|
|
300
349
|
}
|
|
301
350
|
}
|
|
302
351
|
async startBackend() {
|
|
352
|
+
const generation = ++this.backendGeneration;
|
|
353
|
+
const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
|
|
303
354
|
this.logger?.debug('starting Copilot ACP process', {
|
|
304
355
|
command: this.command,
|
|
305
356
|
args: ['--acp'],
|
|
@@ -315,13 +366,20 @@ export class CopilotCliClient {
|
|
|
315
366
|
});
|
|
316
367
|
child.stderr.resume();
|
|
317
368
|
this.logger?.debug('spawned Copilot ACP process', { pid: child.pid });
|
|
369
|
+
let resolveChildExit;
|
|
370
|
+
this.childExitPromise = new Promise((resolve) => {
|
|
371
|
+
resolveChildExit = resolve;
|
|
372
|
+
});
|
|
318
373
|
child.once('error', (error) => {
|
|
319
374
|
this.logger?.debugError('Copilot ACP process error', error);
|
|
375
|
+
if (!ownsCurrentBackend()) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
320
378
|
this.failAll(new Error(`Copilot ACP process error: ${normalizeError(error).message}`));
|
|
321
379
|
});
|
|
322
380
|
child.once('exit', (code, signal) => {
|
|
323
|
-
|
|
324
|
-
if (
|
|
381
|
+
resolveChildExit();
|
|
382
|
+
if (!ownsCurrentBackend()) {
|
|
325
383
|
this.logger?.debug('Copilot ACP process exited during shutdown', { code, signal });
|
|
326
384
|
return;
|
|
327
385
|
}
|
|
@@ -329,9 +387,6 @@ export class CopilotCliClient {
|
|
|
329
387
|
const suffix = signal ? `signal ${signal}` : `code ${String(code ?? 'unknown')}`;
|
|
330
388
|
this.failAll(new Error(`Copilot ACP process exited unexpectedly (${suffix})`));
|
|
331
389
|
});
|
|
332
|
-
this.childExitPromise = new Promise((resolve) => {
|
|
333
|
-
this.resolveChildExit = resolve;
|
|
334
|
-
});
|
|
335
390
|
const output = Writable.toWeb(child.stdin);
|
|
336
391
|
const input = Readable.toWeb(child.stdout);
|
|
337
392
|
const stream = this.runtime.ndJsonStream(output, input);
|
|
@@ -341,7 +396,7 @@ export class CopilotCliClient {
|
|
|
341
396
|
const connection = app.connect(stream);
|
|
342
397
|
this.connection = connection;
|
|
343
398
|
void connection.closed.then(() => {
|
|
344
|
-
if (
|
|
399
|
+
if (ownsCurrentBackend()) {
|
|
345
400
|
this.logger?.debugError('Copilot ACP connection closed unexpectedly');
|
|
346
401
|
this.failAll(new Error('Copilot ACP connection closed unexpectedly'));
|
|
347
402
|
}
|
|
@@ -428,6 +483,7 @@ export class CopilotCliClient {
|
|
|
428
483
|
finally {
|
|
429
484
|
state.processing = false;
|
|
430
485
|
this.reconcileIdleChannelState(channelId, state);
|
|
486
|
+
this.idleBackendShutdown.reconcile();
|
|
431
487
|
}
|
|
432
488
|
})();
|
|
433
489
|
}
|
|
@@ -643,6 +699,7 @@ export class CopilotCliClient {
|
|
|
643
699
|
}
|
|
644
700
|
this.fatalError = error;
|
|
645
701
|
this.rejectFatalPromise(error);
|
|
702
|
+
this.idleBackendShutdown.cancel();
|
|
646
703
|
for (const [channelId, state] of this.channels.entries()) {
|
|
647
704
|
this.clearIdleTimer(state);
|
|
648
705
|
state.activeTurn?.reject(error);
|
|
@@ -884,6 +941,64 @@ export class CopilotCliClient {
|
|
|
884
941
|
return;
|
|
885
942
|
}
|
|
886
943
|
this.backendClosed = true;
|
|
944
|
+
await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Backend teardown without the fatal latch: the client stays usable and
|
|
948
|
+
* `ensureStarted()` spawns a replacement process on the next turn. Only safe
|
|
949
|
+
* while `isBackendIdle()` holds, because every channel's live ACP session is
|
|
950
|
+
* dropped here and no turn is ever replayed onto its successor.
|
|
951
|
+
*/
|
|
952
|
+
async shutdownIdleBackend() {
|
|
953
|
+
// Detach the running backend before any signal reaches it, so its own exit
|
|
954
|
+
// and connection-closed handlers read this teardown as deliberate instead
|
|
955
|
+
// of latching a fatal error on a client that stays usable.
|
|
956
|
+
this.backendGeneration += 1;
|
|
957
|
+
const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
|
|
958
|
+
const sessions = [];
|
|
959
|
+
for (const [channelId, state] of [...this.channels.entries()]) {
|
|
960
|
+
this.clearIdleTimer(state);
|
|
961
|
+
if (state.session) {
|
|
962
|
+
sessions.push(state.session);
|
|
963
|
+
}
|
|
964
|
+
state.session = undefined;
|
|
965
|
+
state.sessionCwd = undefined;
|
|
966
|
+
this.channels.delete(channelId);
|
|
967
|
+
}
|
|
968
|
+
this.logger?.debug('stopping idle Copilot ACP backend', {
|
|
969
|
+
channels: sessions.length,
|
|
970
|
+
idleShutdownMs: this.runtime.idleBackendShutdownMs,
|
|
971
|
+
});
|
|
972
|
+
for (const session of sessions) {
|
|
973
|
+
this.closeSession(session);
|
|
974
|
+
}
|
|
975
|
+
const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).finally(() => {
|
|
976
|
+
this.startPromise = undefined;
|
|
977
|
+
this.childExitPromise = undefined;
|
|
978
|
+
this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
979
|
+
this.idleShutdownPromise = undefined;
|
|
980
|
+
});
|
|
981
|
+
this.idleShutdownPromise = idleShutdownPromise;
|
|
982
|
+
await idleShutdownPromise;
|
|
983
|
+
}
|
|
984
|
+
isBackendIdle() {
|
|
985
|
+
if (this.disposing || this.fatalError || this.backendClosed || this.idleShutdownPromise) {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
if (!this.child) {
|
|
989
|
+
return false;
|
|
990
|
+
}
|
|
991
|
+
if (this.pendingSessionStarts.size > 0) {
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
for (const state of this.channels.values()) {
|
|
995
|
+
if (state.activeTurn || state.queue.length > 0 || state.sessionPromise || state.processing) {
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
async closeBackendProcess(error, gracePeriodMs) {
|
|
887
1002
|
const connection = this.connection;
|
|
888
1003
|
const child = this.child;
|
|
889
1004
|
await this.waitForPendingSessionStarts();
|
|
@@ -895,9 +1010,9 @@ export class CopilotCliClient {
|
|
|
895
1010
|
return;
|
|
896
1011
|
}
|
|
897
1012
|
const childExitPromise = this.childExitPromise ?? Promise.resolve();
|
|
898
|
-
this.logger?.debug('sending SIGTERM to Copilot ACP process');
|
|
1013
|
+
this.logger?.debug('sending SIGTERM to Copilot ACP process', { gracePeriodMs });
|
|
899
1014
|
child.kill('SIGTERM');
|
|
900
|
-
const exitedAfterTerm = await this.waitForChildExit(childExitPromise,
|
|
1015
|
+
const exitedAfterTerm = await this.waitForChildExit(childExitPromise, gracePeriodMs);
|
|
901
1016
|
if (exitedAfterTerm) {
|
|
902
1017
|
this.logger?.debug('Copilot ACP process exited after SIGTERM');
|
|
903
1018
|
this.child = undefined;
|