@borgee/agents-host 0.2.73 → 0.2.84
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 +17 -30
- package/dist/agents-host-supervisor.d.ts +1 -0
- package/dist/agents-host-supervisor.js +3 -1
- package/dist/agents-host.d.ts +6 -4
- package/dist/agents-host.js +117 -16
- package/dist/background-runs.d.ts +23 -0
- package/dist/background-runs.js +153 -0
- package/dist/chat/chat-control-plane.d.ts +6 -1
- package/dist/chat/sdk-chat-control-plane.d.ts +4 -2
- package/dist/chat/sdk-chat-control-plane.js +7 -0
- package/dist/context/claude-file-brief.js +3 -3
- package/dist/context/injection.d.ts +18 -12
- package/dist/context/injection.js +53 -53
- package/dist/context/prompt.js +10 -10
- package/dist/context/resolved-working-folder.d.ts +3 -0
- package/dist/context/resolved-working-folder.js +106 -0
- package/dist/context/turn-preparation.js +1 -1
- package/dist/local-config.d.ts +7 -0
- package/dist/local-config.js +158 -12
- package/dist/managed-daemon.js +42 -35
- package/dist/plugin-sdk.js +179 -9
- package/dist/plugin-sdk.js.map +3 -3
- package/dist/policy/authorization-audit.d.ts +1 -1
- package/dist/policy/copilot-permission.d.ts +9 -0
- package/dist/policy/copilot-permission.js +120 -1
- package/dist/progress-to-activity.d.ts +1 -1
- package/dist/progress-to-activity.js +1 -0
- package/dist/providers/claude/adapter.d.ts +1 -1
- package/dist/providers/claude/adapter.js +7 -0
- package/dist/providers/claude/cli-client.js +4 -3
- package/dist/providers/codex/adapter.d.ts +1 -1
- package/dist/providers/codex/adapter.js +7 -0
- package/dist/providers/codex/cli-client.js +8 -7
- package/dist/providers/codex/project-doc.js +6 -6
- package/dist/providers/copilot/adapter.d.ts +6 -2
- package/dist/providers/copilot/adapter.js +27 -1
- package/dist/providers/copilot/cli-client.d.ts +37 -10
- package/dist/providers/copilot/cli-client.js +751 -122
- package/dist/providers/copilot/sdk-session.d.ts +149 -0
- package/dist/providers/copilot/sdk-session.js +981 -0
- package/dist/providers/create-provider.js +33 -0
- package/dist/providers/provider-adapter.d.ts +16 -1
- package/dist/providers/provider-adapter.js +13 -0
- package/dist/types.d.ts +32 -11
- package/package.json +3 -2
- package/skills/borgee-agent/references/task-properties.md +3 -3
- package/skills/borgee-agent/scripts/borgee-agent.mjs +1 -1
- package/skills/borgee-agent/scripts/borgee-agent.py +1 -1
- package/dist/context/resolved-workspace.d.ts +0 -3
- package/dist/context/resolved-workspace.js +0 -106
|
@@ -2,26 +2,33 @@ import { Readable, Writable } from 'node:stream';
|
|
|
2
2
|
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
|
-
import { AcpProgressCollector } from '../acp-progress-collector.js';
|
|
5
|
+
import { AcpProgressCollector, } from '../acp-progress-collector.js';
|
|
6
6
|
import { ProviderTurnCancelledError } from '../provider-adapter.js';
|
|
7
|
-
import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
|
|
8
|
-
import {
|
|
7
|
+
import { resolveCopilotPermissionResponse, resolveCopilotSdkPermissionResponse, } from '../../policy/copilot-permission.js';
|
|
8
|
+
import { isDiscussionOnlyResolvedWorkingFolder } from '../../context/resolved-working-folder.js';
|
|
9
9
|
import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
|
|
10
|
+
import { CopilotSdkSessionAdapter, createCopilotSdkClient, createCopilotSdkSessionConfig, } from './sdk-session.js';
|
|
10
11
|
const SESSION_TAINTED_ERRORS = new WeakSet();
|
|
11
12
|
const DEFAULT_IDLE_SESSION_TTL_MS = 2 * 24 * 60 * 60 * 1000;
|
|
12
|
-
const IDLE_BACKEND_STOPPED_MESSAGE = 'Copilot
|
|
13
|
+
const IDLE_BACKEND_STOPPED_MESSAGE = 'Copilot runtime backend stopped after idle timeout';
|
|
13
14
|
/**
|
|
14
15
|
* A fatal teardown has callers waiting on it, so it force-kills quickly. An
|
|
15
16
|
* idle recycle has nobody waiting, and the CLIs unlink their on-disk session
|
|
16
17
|
* locks during SIGTERM cleanup — a lock left behind by SIGKILL makes the next
|
|
17
|
-
* restore believe the session is still in use
|
|
18
|
-
*
|
|
18
|
+
* restore believe the session is still in use. The idle path therefore trades
|
|
19
|
+
* latency nobody observes for a clean exit.
|
|
19
20
|
*/
|
|
20
21
|
const DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS = 5_000;
|
|
22
|
+
const DEFAULT_SESSION_CLOSE_TIMEOUT_MS = 5_000;
|
|
21
23
|
const DEFAULT_SHUTDOWN_GRACE_PERIOD_MS = 250;
|
|
22
24
|
const DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS = 250;
|
|
23
|
-
const QUEUED_TURN_DROPPED_MESSAGE = 'Copilot
|
|
25
|
+
const QUEUED_TURN_DROPPED_MESSAGE = 'Copilot session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
|
|
26
|
+
function isCopilotSdkSession(session) {
|
|
27
|
+
return session instanceof CopilotSdkSessionAdapter;
|
|
28
|
+
}
|
|
24
29
|
const DEFAULT_RUNTIME = {
|
|
30
|
+
transport: 'sdk',
|
|
31
|
+
createSdkClient: createCopilotSdkClient,
|
|
25
32
|
spawn,
|
|
26
33
|
client,
|
|
27
34
|
ndJsonStream,
|
|
@@ -31,6 +38,7 @@ const DEFAULT_RUNTIME = {
|
|
|
31
38
|
idleSessionTtlMs: DEFAULT_IDLE_SESSION_TTL_MS,
|
|
32
39
|
idleBackendShutdownMs: IDLE_BACKEND_SHUTDOWN_DISABLED_MS,
|
|
33
40
|
idleShutdownGracePeriodMs: DEFAULT_IDLE_SHUTDOWN_GRACE_PERIOD_MS,
|
|
41
|
+
sessionCloseTimeoutMs: DEFAULT_SESSION_CLOSE_TIMEOUT_MS,
|
|
34
42
|
shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
|
|
35
43
|
shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
|
|
36
44
|
fetch: async (input, init) => fetch(input, init),
|
|
@@ -38,6 +46,7 @@ const DEFAULT_RUNTIME = {
|
|
|
38
46
|
const DEFAULT_SESSION_CAPABILITIES = {
|
|
39
47
|
loadSession: false,
|
|
40
48
|
resumeSession: false,
|
|
49
|
+
closeSession: false,
|
|
41
50
|
};
|
|
42
51
|
function isAbsoluteHttpUrl(value) {
|
|
43
52
|
return value.startsWith('http://') || value.startsWith('https://');
|
|
@@ -75,6 +84,7 @@ function createDeferredTurn(channelId, preparedTurn, sessionPersistence, options
|
|
|
75
84
|
sessionPersistence,
|
|
76
85
|
options,
|
|
77
86
|
promise,
|
|
87
|
+
cancelled: false,
|
|
78
88
|
resolve(value) {
|
|
79
89
|
if (settled)
|
|
80
90
|
return;
|
|
@@ -97,20 +107,61 @@ function parsePersistedSessionRecord(rawValue) {
|
|
|
97
107
|
if (trimmed.startsWith('{')) {
|
|
98
108
|
const parsed = JSON.parse(trimmed);
|
|
99
109
|
if (typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0) {
|
|
110
|
+
const backgroundTaskExecutions = parseBackgroundTaskExecutions(parsed.backgroundTaskExecutions);
|
|
100
111
|
return {
|
|
101
112
|
sessionId: parsed.sessionId,
|
|
102
113
|
cwd: typeof parsed.cwd === 'string' && parsed.cwd.trim().length > 0
|
|
103
114
|
? parsed.cwd
|
|
104
115
|
: undefined,
|
|
116
|
+
...(backgroundTaskExecutions ? { backgroundTaskExecutions } : {}),
|
|
105
117
|
};
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
return { sessionId: rawValue };
|
|
109
121
|
}
|
|
122
|
+
function parseBackgroundTaskExecutions(value) {
|
|
123
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const executions = {};
|
|
127
|
+
for (const [taskId, candidate] of Object.entries(value).slice(0, 128)) {
|
|
128
|
+
if (!taskId ||
|
|
129
|
+
!candidate ||
|
|
130
|
+
typeof candidate !== 'object' ||
|
|
131
|
+
Array.isArray(candidate)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const execution = candidate;
|
|
135
|
+
if (typeof execution.startedAt !== 'number' ||
|
|
136
|
+
!Number.isFinite(execution.startedAt) ||
|
|
137
|
+
typeof execution.terminal !== 'boolean' ||
|
|
138
|
+
typeof execution.waiting !== 'boolean') {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const activeStartedAt = typeof execution.activeStartedAt === 'number' &&
|
|
142
|
+
Number.isFinite(execution.activeStartedAt)
|
|
143
|
+
? execution.activeStartedAt
|
|
144
|
+
: undefined;
|
|
145
|
+
const terminalAt = typeof execution.terminalAt === 'number' && Number.isFinite(execution.terminalAt)
|
|
146
|
+
? execution.terminalAt
|
|
147
|
+
: undefined;
|
|
148
|
+
executions[taskId] = {
|
|
149
|
+
startedAt: execution.startedAt,
|
|
150
|
+
terminal: execution.terminal,
|
|
151
|
+
waiting: execution.waiting,
|
|
152
|
+
...(activeStartedAt !== undefined ? { activeStartedAt } : {}),
|
|
153
|
+
...(terminalAt !== undefined ? { terminalAt } : {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return Object.keys(executions).length > 0 ? executions : undefined;
|
|
157
|
+
}
|
|
110
158
|
function serializePersistedSessionRecord(record) {
|
|
111
159
|
return JSON.stringify({
|
|
112
160
|
sessionId: record.sessionId,
|
|
113
161
|
...(record.cwd ? { cwd: record.cwd } : {}),
|
|
162
|
+
...(record.backgroundTaskExecutions
|
|
163
|
+
? { backgroundTaskExecutions: record.backgroundTaskExecutions }
|
|
164
|
+
: {}),
|
|
114
165
|
});
|
|
115
166
|
}
|
|
116
167
|
function resolveSessionRouting(turn) {
|
|
@@ -136,6 +187,7 @@ function readSessionCapabilities(response) {
|
|
|
136
187
|
return {
|
|
137
188
|
loadSession: agentCapabilities?.loadSession === true,
|
|
138
189
|
resumeSession: asObject(sessionCapabilities?.resume) !== null,
|
|
190
|
+
closeSession: asObject(sessionCapabilities?.close) !== null,
|
|
139
191
|
};
|
|
140
192
|
}
|
|
141
193
|
function markSessionTainted(error) {
|
|
@@ -146,13 +198,64 @@ function markSessionTainted(error) {
|
|
|
146
198
|
function isSessionTainted(error) {
|
|
147
199
|
return error instanceof Error && SESSION_TAINTED_ERRORS.has(error);
|
|
148
200
|
}
|
|
201
|
+
function isAgentTaskInput(value) {
|
|
202
|
+
const input = asObject(value);
|
|
203
|
+
return typeof input?.agent_type === 'string'
|
|
204
|
+
&& typeof input.name === 'string'
|
|
205
|
+
&& typeof input.description === 'string'
|
|
206
|
+
&& typeof input.prompt === 'string'
|
|
207
|
+
&& (input.mode === undefined
|
|
208
|
+
|| input.mode === 'sync'
|
|
209
|
+
|| input.mode === 'background');
|
|
210
|
+
}
|
|
211
|
+
const COPILOT_BACKGROUND_AGENT_ID = '[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
|
|
212
|
+
const COPILOT_SYNC_TASK_TIMEOUT_RESULT = new RegExp('^Agent is still running \\(agent_id: '
|
|
213
|
+
+ COPILOT_BACKGROUND_AGENT_ID
|
|
214
|
+
+ '\\)\\.'
|
|
215
|
+
+ '(?: The agent is actively working \\([\\s\\S]*\\)\\.)?'
|
|
216
|
+
+ ' Use read_agent to check for results'
|
|
217
|
+
+ '(?:, or write_agent to send follow-up messages)?\\.$', 'i');
|
|
218
|
+
function isBackgroundAgentTaskResult(value) {
|
|
219
|
+
const result = asObject(value);
|
|
220
|
+
return typeof result?.content === 'string'
|
|
221
|
+
&& COPILOT_SYNC_TASK_TIMEOUT_RESULT.test(result.content.trim());
|
|
222
|
+
}
|
|
223
|
+
class BackgroundAgentLaunchTracker {
|
|
224
|
+
taskCalls = new Map();
|
|
225
|
+
consume(notification) {
|
|
226
|
+
const update = notification.update;
|
|
227
|
+
if (update.sessionUpdate === 'tool_call') {
|
|
228
|
+
if (isAgentTaskInput(update.rawInput)) {
|
|
229
|
+
const input = asObject(update.rawInput);
|
|
230
|
+
this.taskCalls.set(update.toolCallId, {
|
|
231
|
+
requestedBackground: input?.mode === 'background',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (update.sessionUpdate !== 'tool_call_update'
|
|
237
|
+
|| !this.taskCalls.has(update.toolCallId)) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
if (update.status === 'failed') {
|
|
241
|
+
this.taskCalls.delete(update.toolCallId);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (update.status !== 'completed') {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const taskCall = this.taskCalls.get(update.toolCallId);
|
|
248
|
+
this.taskCalls.delete(update.toolCallId);
|
|
249
|
+
return taskCall.requestedBackground || isBackgroundAgentTaskResult(update.rawOutput);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
149
252
|
/**
|
|
150
|
-
* Persistent
|
|
253
|
+
* Persistent session client for the GitHub Copilot CLI.
|
|
151
254
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
255
|
+
* Production uses the Copilot SDK headless JSON-RPC transport. The ACP runtime
|
|
256
|
+
* remains injectable for compatibility tests. Both paths reuse one provider
|
|
257
|
+
* session per Borgee channel because the background-agent registry belongs to
|
|
258
|
+
* that session and later read_agent/write_agent turns must keep that identity.
|
|
156
259
|
*/
|
|
157
260
|
export class CopilotCliClient {
|
|
158
261
|
command;
|
|
@@ -163,22 +266,75 @@ export class CopilotCliClient {
|
|
|
163
266
|
imageInputConfig;
|
|
164
267
|
runtime;
|
|
165
268
|
channels = new Map();
|
|
269
|
+
channelWorkerRoutes = new Map();
|
|
270
|
+
sessionAuthorizationLeases = new Map();
|
|
166
271
|
/**
|
|
167
272
|
* The provider session currently bound to channelId, or undefined before a
|
|
168
273
|
* turn has opened one. Surfaced so agents-host can record which session
|
|
169
274
|
* worked a task without the agent being asked to report its own id.
|
|
170
275
|
*/
|
|
171
276
|
sessionIdForChannel(channelId) {
|
|
172
|
-
return this.
|
|
277
|
+
return this.channelWorkerRoutes.get(channelId)?.state.session?.sessionId
|
|
278
|
+
?? this.channels.get(channelId)?.session?.sessionId;
|
|
173
279
|
}
|
|
174
|
-
async
|
|
175
|
-
const
|
|
280
|
+
async interruptTurn(channelId) {
|
|
281
|
+
const workerRoute = this.channelWorkerRoutes.get(channelId);
|
|
282
|
+
const state = workerRoute?.phase === 'promoted' && workerRoute.state.activeTurn
|
|
283
|
+
? workerRoute.state
|
|
284
|
+
: this.channels.get(channelId) ?? workerRoute?.state;
|
|
285
|
+
const activeTurn = state?.activeTurn;
|
|
176
286
|
const session = state?.session;
|
|
177
|
-
if (!state
|
|
287
|
+
if (!state || !activeTurn) {
|
|
178
288
|
return false;
|
|
179
289
|
}
|
|
290
|
+
activeTurn.cancelled = true;
|
|
291
|
+
if (!session) {
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
if (!isCopilotSdkSession(session)) {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
180
297
|
this.clearIdleTimer(state);
|
|
181
298
|
this.idleBackendShutdown.cancel();
|
|
299
|
+
return session.interruptMainTurn();
|
|
300
|
+
}
|
|
301
|
+
async cancelSession(channelId) {
|
|
302
|
+
const workerRoute = this.channelWorkerRoutes.get(channelId);
|
|
303
|
+
const state = workerRoute?.phase === 'promoted'
|
|
304
|
+
? workerRoute.state
|
|
305
|
+
: this.channels.get(channelId) ?? workerRoute?.state;
|
|
306
|
+
const session = state?.session;
|
|
307
|
+
if (!state) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
if (state.activeTurn) {
|
|
311
|
+
state.activeTurn.cancelled = true;
|
|
312
|
+
}
|
|
313
|
+
if (!session) {
|
|
314
|
+
if (!state.activeTurn) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
state.activeTurn.cancelled = true;
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
this.clearIdleTimer(state);
|
|
321
|
+
this.idleBackendShutdown.cancel();
|
|
322
|
+
if (isCopilotSdkSession(session)) {
|
|
323
|
+
const [foreground, background] = await Promise.allSettled([
|
|
324
|
+
state.activeTurn ? session.interruptMainTurn() : Promise.resolve(false),
|
|
325
|
+
session.cancelAllBackgroundWork(),
|
|
326
|
+
]);
|
|
327
|
+
if (foreground.status === 'rejected' || background.status === 'rejected') {
|
|
328
|
+
const errors = [foreground, background]
|
|
329
|
+
.filter((result) => result.status === 'rejected')
|
|
330
|
+
.map((result) => normalizeError(result.reason));
|
|
331
|
+
throw new AggregateError(errors, 'Copilot SDK session cancellation failed');
|
|
332
|
+
}
|
|
333
|
+
return foreground.value || background.value;
|
|
334
|
+
}
|
|
335
|
+
if (!state.activeTurn) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
182
338
|
const activeSession = session;
|
|
183
339
|
if (typeof activeSession.cancel === 'function') {
|
|
184
340
|
await activeSession.cancel({ sessionId: session.sessionId });
|
|
@@ -188,8 +344,23 @@ export class CopilotCliClient {
|
|
|
188
344
|
await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
|
|
189
345
|
return true;
|
|
190
346
|
}
|
|
347
|
+
async cancelTurn(channelId) {
|
|
348
|
+
return this.cancelSession(channelId);
|
|
349
|
+
}
|
|
350
|
+
async cancelBackgroundRun(channelId, providerRunId) {
|
|
351
|
+
const workerRoute = this.channelWorkerRoutes.get(channelId);
|
|
352
|
+
const state = workerRoute?.phase === 'promoted'
|
|
353
|
+
? workerRoute.state
|
|
354
|
+
: this.channels.get(channelId) ?? workerRoute?.state;
|
|
355
|
+
const session = state?.session;
|
|
356
|
+
if (!session || !isCopilotSdkSession(session)) {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
return session.cancelBackgroundRun(providerRunId);
|
|
360
|
+
}
|
|
191
361
|
persistedSessions = new Map();
|
|
192
362
|
closingSessions = new WeakSet();
|
|
363
|
+
orphanedSessions = new Set();
|
|
193
364
|
pendingSessionStarts = new Set();
|
|
194
365
|
pendingSessionCloses = new Set();
|
|
195
366
|
pendingSessionStoreOperations = new Set();
|
|
@@ -197,6 +368,7 @@ export class CopilotCliClient {
|
|
|
197
368
|
rejectFatalPromise;
|
|
198
369
|
child;
|
|
199
370
|
connection;
|
|
371
|
+
sdkClient;
|
|
200
372
|
startPromise;
|
|
201
373
|
shutdownPromise;
|
|
202
374
|
childExitPromise;
|
|
@@ -207,6 +379,7 @@ export class CopilotCliClient {
|
|
|
207
379
|
*/
|
|
208
380
|
backendGeneration = 0;
|
|
209
381
|
idleShutdownPromise;
|
|
382
|
+
backendShutdownInProgress = false;
|
|
210
383
|
fatalError = null;
|
|
211
384
|
disposing = false;
|
|
212
385
|
backendClosed = false;
|
|
@@ -214,6 +387,8 @@ export class CopilotCliClient {
|
|
|
214
387
|
sessionStoreLoadPromise = null;
|
|
215
388
|
sessionStoreWriteQueue = Promise.resolve();
|
|
216
389
|
sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
390
|
+
backendRecycleRequired = false;
|
|
391
|
+
autonomousReplyHandler;
|
|
217
392
|
idleBackendShutdown;
|
|
218
393
|
constructor(command, _ignoredArgs = [], runtimeOverrides = {}, sessionStore, resolveSessionStoreAgentId = () => undefined, logger = new HostLogger(), permissionPolicy = {}, imageInputConfig = {}) {
|
|
219
394
|
this.command = command;
|
|
@@ -228,7 +403,7 @@ export class CopilotCliClient {
|
|
|
228
403
|
isIdle: () => this.isBackendIdle(),
|
|
229
404
|
shutdown: () => this.shutdownIdleBackend(),
|
|
230
405
|
onShutdownFailed: (error) => {
|
|
231
|
-
this.logger.error('failed to stop the idle Copilot
|
|
406
|
+
this.logger.error('failed to stop the idle Copilot runtime backend', {
|
|
232
407
|
error: summarizeError(error),
|
|
233
408
|
});
|
|
234
409
|
},
|
|
@@ -259,19 +434,26 @@ export class CopilotCliClient {
|
|
|
259
434
|
? maybeOptions
|
|
260
435
|
: promptOrOptions;
|
|
261
436
|
const sessionRouting = resolveSessionRouting(preparedTurn);
|
|
262
|
-
const state = this.
|
|
437
|
+
const state = this.resolveChannelState(sessionRouting);
|
|
438
|
+
if (options?.onProgress) {
|
|
439
|
+
state.backgroundProgress = options.onProgress;
|
|
440
|
+
}
|
|
263
441
|
this.clearIdleTimer(state);
|
|
264
442
|
this.idleBackendShutdown.cancel();
|
|
265
443
|
const turn = createDeferredTurn(preparedTurn.channelId, preparedTurn, sessionRouting.persistence, options);
|
|
266
444
|
state.queue.push(turn);
|
|
267
|
-
this.processChannelQueue(
|
|
445
|
+
this.processChannelQueue(state);
|
|
268
446
|
return turn.promise;
|
|
269
447
|
}
|
|
448
|
+
onAutonomousReply(handler) {
|
|
449
|
+
this.autonomousReplyHandler = handler;
|
|
450
|
+
}
|
|
270
451
|
async dispose() {
|
|
271
|
-
const error = new Error('Copilot
|
|
452
|
+
const error = new Error('Copilot runtime backend stopped');
|
|
272
453
|
this.disposing = true;
|
|
454
|
+
this.autonomousReplyHandler = undefined;
|
|
273
455
|
this.idleBackendShutdown.cancel();
|
|
274
|
-
this.logger?.debug('stopping Copilot
|
|
456
|
+
this.logger?.debug('stopping Copilot runtime backend');
|
|
275
457
|
const closed = this.connection?.closed ?? Promise.resolve();
|
|
276
458
|
this.failAll(error);
|
|
277
459
|
let thrown;
|
|
@@ -314,11 +496,15 @@ export class CopilotCliClient {
|
|
|
314
496
|
if (this.fatalError) {
|
|
315
497
|
throw this.fatalError;
|
|
316
498
|
}
|
|
317
|
-
if (!this.connection) {
|
|
318
|
-
throw new Error('Copilot
|
|
499
|
+
if (this.runtime.transport === 'sdk' ? !this.sdkClient : !this.connection) {
|
|
500
|
+
throw new Error('Copilot runtime connection is not available');
|
|
319
501
|
}
|
|
320
502
|
}
|
|
321
503
|
async startBackend() {
|
|
504
|
+
if (this.runtime.transport === 'sdk') {
|
|
505
|
+
await this.startSdkBackend();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
322
508
|
const generation = ++this.backendGeneration;
|
|
323
509
|
const ownsCurrentBackend = () => (this.backendGeneration === generation && !this.disposing);
|
|
324
510
|
this.logger?.debug('starting Copilot ACP process', {
|
|
@@ -384,6 +570,7 @@ export class CopilotCliClient {
|
|
|
384
570
|
this.logger?.debug('initialized Copilot ACP connection', {
|
|
385
571
|
loadSession: this.sessionCapabilities.loadSession,
|
|
386
572
|
resumeSession: this.sessionCapabilities.resumeSession,
|
|
573
|
+
closeSession: this.sessionCapabilities.closeSession,
|
|
387
574
|
});
|
|
388
575
|
}
|
|
389
576
|
catch (error) {
|
|
@@ -393,25 +580,73 @@ export class CopilotCliClient {
|
|
|
393
580
|
throw normalized;
|
|
394
581
|
}
|
|
395
582
|
}
|
|
396
|
-
|
|
397
|
-
|
|
583
|
+
async startSdkBackend() {
|
|
584
|
+
this.logger?.debug('starting Copilot SDK runtime', {
|
|
585
|
+
command: this.command,
|
|
586
|
+
cwd: this.runtime.cwd,
|
|
587
|
+
});
|
|
588
|
+
const sdkClient = this.runtime.createSdkClient(this.command, this.runtime.cwd, this.logger.enabled);
|
|
589
|
+
this.sdkClient = sdkClient;
|
|
590
|
+
try {
|
|
591
|
+
await sdkClient.start();
|
|
592
|
+
this.sessionCapabilities = {
|
|
593
|
+
loadSession: false,
|
|
594
|
+
resumeSession: true,
|
|
595
|
+
closeSession: true,
|
|
596
|
+
};
|
|
597
|
+
this.logger?.debug('initialized Copilot SDK runtime');
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
const normalized = new Error(`Copilot SDK startup failed: ${normalizeError(error).message}`);
|
|
601
|
+
this.logger?.debugError('Copilot SDK startup failed', normalized);
|
|
602
|
+
this.failAll(normalized);
|
|
603
|
+
throw normalized;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
getOrCreateChannelState(routingKey, resolvedChannelId, sessionPersistence) {
|
|
607
|
+
let state = this.channels.get(routingKey);
|
|
398
608
|
if (!state) {
|
|
399
609
|
state = {
|
|
610
|
+
routingKey,
|
|
400
611
|
channelId: resolvedChannelId,
|
|
401
612
|
sessionPersistence,
|
|
613
|
+
sessionHasBackgroundAgents: false,
|
|
614
|
+
sessionHasPendingAutonomousTurn: false,
|
|
402
615
|
idleTimerGeneration: 0,
|
|
403
616
|
processing: false,
|
|
404
617
|
queue: [],
|
|
405
618
|
};
|
|
406
|
-
this.channels.set(
|
|
619
|
+
this.channels.set(routingKey, state);
|
|
407
620
|
}
|
|
408
621
|
else if (state.channelId !== resolvedChannelId
|
|
409
622
|
|| state.sessionPersistence !== sessionPersistence) {
|
|
410
|
-
throw new Error(`Copilot session routing changed for key "${
|
|
623
|
+
throw new Error(`Copilot session routing changed for key "${routingKey}"`);
|
|
411
624
|
}
|
|
412
625
|
return state;
|
|
413
626
|
}
|
|
414
|
-
|
|
627
|
+
resolveChannelState(routing) {
|
|
628
|
+
const workerRoute = this.channelWorkerRoutes.get(routing.channelId);
|
|
629
|
+
if (workerRoute) {
|
|
630
|
+
// Candidate ownership reserves the whole channel so a foreground turn
|
|
631
|
+
// cannot open a competing worker registry before promotion.
|
|
632
|
+
return workerRoute.state;
|
|
633
|
+
}
|
|
634
|
+
if (routing.persistence === 'ephemeral') {
|
|
635
|
+
const foregroundState = this.channels.get(routing.channelId);
|
|
636
|
+
const state = foregroundState
|
|
637
|
+
&& foregroundState.channelId === routing.channelId
|
|
638
|
+
&& foregroundState.sessionPersistence === 'persistent'
|
|
639
|
+
? foregroundState
|
|
640
|
+
: this.getOrCreateChannelState(routing.key, routing.channelId, routing.persistence);
|
|
641
|
+
this.channelWorkerRoutes.set(routing.channelId, {
|
|
642
|
+
phase: 'candidate',
|
|
643
|
+
state,
|
|
644
|
+
});
|
|
645
|
+
return state;
|
|
646
|
+
}
|
|
647
|
+
return this.getOrCreateChannelState(routing.key, routing.channelId, routing.persistence);
|
|
648
|
+
}
|
|
649
|
+
processChannelQueue(state) {
|
|
415
650
|
if (state.processing) {
|
|
416
651
|
return;
|
|
417
652
|
}
|
|
@@ -426,20 +661,42 @@ export class CopilotCliClient {
|
|
|
426
661
|
state.activeTurn = turn;
|
|
427
662
|
try {
|
|
428
663
|
state.cwd = await this.resolveSessionCwd(turn.preparedTurn.promptContext);
|
|
429
|
-
state.toolPermissionMode =
|
|
664
|
+
state.toolPermissionMode = isDiscussionOnlyResolvedWorkingFolder(turn.preparedTurn.promptContext)
|
|
430
665
|
? 'deny-all'
|
|
431
666
|
: 'default';
|
|
667
|
+
const adoptingPersistentRoute = (turn.sessionPersistence === 'persistent'
|
|
668
|
+
&& state.sessionPersistence === 'ephemeral');
|
|
669
|
+
if (adoptingPersistentRoute) {
|
|
670
|
+
// Delay the upgrade until this turn reaches the queue head so
|
|
671
|
+
// enqueueing it cannot change or abort the active kickoff.
|
|
672
|
+
state.sessionPersistence = 'persistent';
|
|
673
|
+
}
|
|
432
674
|
await this.ensureStarted();
|
|
433
|
-
await this.recycleSessionIfCwdChanged(
|
|
434
|
-
const
|
|
675
|
+
await this.recycleSessionIfCwdChanged(state);
|
|
676
|
+
const existingSession = state.session;
|
|
677
|
+
const session = await this.getOrCreateSession(state);
|
|
678
|
+
if (turn.cancelled) {
|
|
679
|
+
throw new ProviderTurnCancelledError('Copilot turn cancelled');
|
|
680
|
+
}
|
|
681
|
+
if (adoptingPersistentRoute && existingSession === session) {
|
|
682
|
+
await this.persistSessionBestEffort(state, session.sessionId, state.sessionCwd ?? state.cwd ?? this.runtime.cwd);
|
|
683
|
+
}
|
|
684
|
+
this.bindSessionAuthorization(session, state);
|
|
435
685
|
let reply;
|
|
436
686
|
try {
|
|
437
687
|
const promptInput = await this.buildPromptInput(turn.preparedTurn);
|
|
438
|
-
|
|
688
|
+
if (turn.cancelled) {
|
|
689
|
+
throw new ProviderTurnCancelledError('Copilot turn cancelled');
|
|
690
|
+
}
|
|
691
|
+
reply = await this.runTurn(session, promptInput, turn.options, () => {
|
|
692
|
+
if (state.session === session) {
|
|
693
|
+
this.promoteWorkerRoute(state, session);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
439
696
|
}
|
|
440
697
|
catch (error) {
|
|
441
698
|
if (isSessionTainted(error)) {
|
|
442
|
-
this.invalidateSession(
|
|
699
|
+
this.invalidateSession(state, session);
|
|
443
700
|
this.rejectQueuedTurnsAfterSessionTaint(state, error);
|
|
444
701
|
}
|
|
445
702
|
throw error;
|
|
@@ -456,28 +713,28 @@ export class CopilotCliClient {
|
|
|
456
713
|
}
|
|
457
714
|
finally {
|
|
458
715
|
state.processing = false;
|
|
459
|
-
this.reconcileIdleChannelState(
|
|
460
|
-
this.
|
|
716
|
+
this.reconcileIdleChannelState(state);
|
|
717
|
+
this.reconcileBackendLifecycle();
|
|
461
718
|
}
|
|
462
719
|
})();
|
|
463
720
|
}
|
|
464
|
-
async getOrCreateSession(
|
|
721
|
+
async getOrCreateSession(state) {
|
|
465
722
|
if (state.session) {
|
|
466
723
|
return state.session;
|
|
467
724
|
}
|
|
468
725
|
if (state.sessionPromise) {
|
|
469
726
|
return state.sessionPromise;
|
|
470
727
|
}
|
|
471
|
-
if (!this.connection) {
|
|
472
|
-
throw new Error('Copilot
|
|
728
|
+
if (this.runtime.transport === 'sdk' ? !this.sdkClient : !this.connection) {
|
|
729
|
+
throw new Error('Copilot runtime connection is not available');
|
|
473
730
|
}
|
|
474
731
|
const cwd = state.cwd ?? this.runtime.cwd;
|
|
475
732
|
const persistedSessionId = state.sessionPersistence === 'persistent'
|
|
476
|
-
? await this.readPersistedSessionId(channelId, cwd)
|
|
733
|
+
? await this.readPersistedSessionId(state.channelId, cwd)
|
|
477
734
|
: undefined;
|
|
478
735
|
const sessionPromise = persistedSessionId
|
|
479
|
-
? this.restoreOrCreateSession(
|
|
480
|
-
: this.startFreshSession(
|
|
736
|
+
? this.restoreOrCreateSession(state, persistedSessionId, cwd)
|
|
737
|
+
: this.startFreshSession(state, cwd);
|
|
481
738
|
this.pendingSessionStarts.add(sessionPromise);
|
|
482
739
|
state.sessionPromise = sessionPromise;
|
|
483
740
|
let sessionAdopted = false;
|
|
@@ -501,6 +758,19 @@ export class CopilotCliClient {
|
|
|
501
758
|
sessionAdopted = true;
|
|
502
759
|
state.session = session;
|
|
503
760
|
state.sessionCwd = cwd;
|
|
761
|
+
state.sessionHasBackgroundAgents = false;
|
|
762
|
+
state.sessionHasPendingAutonomousTurn = false;
|
|
763
|
+
if (isCopilotSdkSession(session)) {
|
|
764
|
+
await session.reconcileBackgroundTasks();
|
|
765
|
+
const hasBackgroundAgents = await session.hasBackgroundAgents();
|
|
766
|
+
if (!state.sessionHasBackgroundAgents && hasBackgroundAgents) {
|
|
767
|
+
this.promoteWorkerRoute(state, session);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (state.sessionPersistence === 'persistent'
|
|
771
|
+
&& session.sessionId !== persistedSessionId) {
|
|
772
|
+
await this.persistSessionBestEffort(state, session.sessionId, cwd);
|
|
773
|
+
}
|
|
504
774
|
return session;
|
|
505
775
|
}
|
|
506
776
|
catch (error) {
|
|
@@ -514,27 +784,33 @@ export class CopilotCliClient {
|
|
|
514
784
|
}
|
|
515
785
|
}
|
|
516
786
|
}
|
|
517
|
-
async startFreshSession(
|
|
787
|
+
async startFreshSession(state, cwd) {
|
|
788
|
+
if (this.runtime.transport === 'sdk') {
|
|
789
|
+
if (!this.sdkClient) {
|
|
790
|
+
throw new Error('Copilot SDK runtime is not available');
|
|
791
|
+
}
|
|
792
|
+
const session = this.createSdkSessionAdapter(await this.sdkClient.createSession(createCopilotSdkSessionConfig(cwd, (request, invocation) => this.handleSdkPermissionRequest(request, invocation.sessionId))), state);
|
|
793
|
+
this.logger?.debug('started fresh Copilot SDK session', { channelId: state.channelId });
|
|
794
|
+
return session;
|
|
795
|
+
}
|
|
518
796
|
if (!this.connection) {
|
|
519
797
|
throw new Error('Copilot ACP connection is not available');
|
|
520
798
|
}
|
|
521
799
|
const session = await this.connection.agent.buildSession(cwd).start();
|
|
522
|
-
this.logger?.debug('started fresh Copilot ACP session', { channelId });
|
|
523
|
-
const state = this.channels.get(channelId);
|
|
524
|
-
if (state?.sessionPersistence === 'persistent') {
|
|
525
|
-
await this.persistSessionBestEffort(channelId, session.sessionId, cwd);
|
|
526
|
-
}
|
|
800
|
+
this.logger?.debug('started fresh Copilot ACP session', { channelId: state.channelId });
|
|
527
801
|
return session;
|
|
528
802
|
}
|
|
529
|
-
async restoreOrCreateSession(
|
|
803
|
+
async restoreOrCreateSession(state, sessionId, cwd) {
|
|
530
804
|
if (!this.sessionCapabilities.resumeSession && !this.sessionCapabilities.loadSession) {
|
|
531
|
-
this.logger?.debug('Copilot
|
|
532
|
-
|
|
805
|
+
this.logger?.debug('Copilot session restore unsupported; starting fresh session', {
|
|
806
|
+
channelId: state.channelId,
|
|
807
|
+
});
|
|
808
|
+
return this.startFreshSession(state, cwd);
|
|
533
809
|
}
|
|
534
810
|
try {
|
|
535
|
-
const session = await this.restoreSession(sessionId, cwd);
|
|
536
|
-
this.logger?.debug('restored Copilot
|
|
537
|
-
channelId,
|
|
811
|
+
const session = await this.restoreSession(state, sessionId, cwd);
|
|
812
|
+
this.logger?.debug('restored Copilot session', {
|
|
813
|
+
channelId: state.channelId,
|
|
538
814
|
restoreMethod: this.sessionCapabilities.resumeSession ? 'session/resume' : 'session/load',
|
|
539
815
|
});
|
|
540
816
|
return session;
|
|
@@ -543,12 +819,21 @@ export class CopilotCliClient {
|
|
|
543
819
|
if (!isStaleRestoreFailure(error)) {
|
|
544
820
|
throw normalizeError(error);
|
|
545
821
|
}
|
|
546
|
-
this.logger?.debug('discarded stale Copilot
|
|
547
|
-
|
|
548
|
-
|
|
822
|
+
this.logger?.debug('discarded stale Copilot session and started fresh', {
|
|
823
|
+
channelId: state.channelId,
|
|
824
|
+
});
|
|
825
|
+
await this.clearPersistedSessionBestEffort(state.channelId, sessionId);
|
|
826
|
+
return this.startFreshSession(state, cwd);
|
|
549
827
|
}
|
|
550
828
|
}
|
|
551
|
-
async restoreSession(sessionId, cwd) {
|
|
829
|
+
async restoreSession(state, sessionId, cwd) {
|
|
830
|
+
if (this.runtime.transport === 'sdk') {
|
|
831
|
+
if (!this.sdkClient) {
|
|
832
|
+
throw new Error('Copilot SDK runtime is not available');
|
|
833
|
+
}
|
|
834
|
+
const session = await this.sdkClient.resumeSession(sessionId, createCopilotSdkSessionConfig(cwd, (request, invocation) => this.handleSdkPermissionRequest(request, invocation.sessionId)));
|
|
835
|
+
return this.createSdkSessionAdapter(session, state);
|
|
836
|
+
}
|
|
552
837
|
if (!this.connection) {
|
|
553
838
|
throw new Error('Copilot ACP connection is not available');
|
|
554
839
|
}
|
|
@@ -578,18 +863,73 @@ export class CopilotCliClient {
|
|
|
578
863
|
throw new Error('Copilot ACP agent does not advertise session restore capabilities');
|
|
579
864
|
}
|
|
580
865
|
catch (error) {
|
|
581
|
-
|
|
866
|
+
this.closeSession(session);
|
|
582
867
|
throw normalizeError(error);
|
|
583
868
|
}
|
|
584
869
|
}
|
|
870
|
+
createSdkSessionAdapter(session, state) {
|
|
871
|
+
const persistedRecord = this.persistedSessions.get(state.channelId);
|
|
872
|
+
const initialBackgroundTaskExecutions = persistedRecord?.sessionId === session.sessionId
|
|
873
|
+
? persistedRecord.backgroundTaskExecutions
|
|
874
|
+
: undefined;
|
|
875
|
+
return new CopilotSdkSessionAdapter(session, (run) => {
|
|
876
|
+
state.backgroundProgress?.({ type: 'background_run', run });
|
|
877
|
+
}, (active) => {
|
|
878
|
+
const currentSession = state.session;
|
|
879
|
+
if (!currentSession || currentSession.sessionId !== session.sessionId) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (active) {
|
|
883
|
+
this.promoteWorkerRoute(state, currentSession);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
state.sessionHasBackgroundAgents = false;
|
|
887
|
+
this.reconcileIdleChannelState(state);
|
|
888
|
+
this.reconcileBackendLifecycle();
|
|
889
|
+
}, (error) => {
|
|
890
|
+
this.logger.debugError('failed to refresh Copilot background task registry', {
|
|
891
|
+
channelId: state.channelId,
|
|
892
|
+
error: summarizeError(error),
|
|
893
|
+
});
|
|
894
|
+
}, (reply) => {
|
|
895
|
+
const currentSession = state.session;
|
|
896
|
+
if (!currentSession || currentSession.sessionId !== session.sessionId) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
this.autonomousReplyHandler?.({
|
|
900
|
+
channelId: state.channelId,
|
|
901
|
+
eventId: reply.eventId,
|
|
902
|
+
text: reply.text,
|
|
903
|
+
});
|
|
904
|
+
}, (active) => {
|
|
905
|
+
const currentSession = state.session;
|
|
906
|
+
if (!currentSession || currentSession.sessionId !== session.sessionId) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
state.sessionHasPendingAutonomousTurn = active;
|
|
910
|
+
if (active) {
|
|
911
|
+
this.promoteWorkerRoute(state, currentSession);
|
|
912
|
+
this.clearIdleTimer(state);
|
|
913
|
+
this.idleBackendShutdown.cancel();
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
this.reconcileIdleChannelState(state);
|
|
917
|
+
this.reconcileBackendLifecycle();
|
|
918
|
+
}, initialBackgroundTaskExecutions, async (backgroundTaskExecutions) => {
|
|
919
|
+
await this.persistSessionBestEffort(state, session.sessionId, state.sessionCwd ?? state.cwd ?? this.runtime.cwd, backgroundTaskExecutions);
|
|
920
|
+
});
|
|
921
|
+
}
|
|
585
922
|
clearBufferedSessionReplay(session) {
|
|
923
|
+
if (isCopilotSdkSession(session)) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
586
926
|
const updates = session.updates;
|
|
587
927
|
if (updates && Array.isArray(updates.values)) {
|
|
588
928
|
updates.values = [];
|
|
589
929
|
}
|
|
590
930
|
}
|
|
591
931
|
async resolveSessionCwd(promptContext) {
|
|
592
|
-
return promptContext?.
|
|
932
|
+
return promptContext?.resolvedWorkingFolder?.rootPath ?? this.runtime.cwd;
|
|
593
933
|
}
|
|
594
934
|
async buildPromptInput(turn) {
|
|
595
935
|
const imageParts = turn.incomingParts.map(asImagePart).filter((part) => part !== null);
|
|
@@ -622,7 +962,7 @@ export class CopilotCliClient {
|
|
|
622
962
|
uri: response.url || resolvedUrl,
|
|
623
963
|
};
|
|
624
964
|
}
|
|
625
|
-
async recycleSessionIfCwdChanged(
|
|
965
|
+
async recycleSessionIfCwdChanged(state) {
|
|
626
966
|
const session = state.session;
|
|
627
967
|
if (!session) {
|
|
628
968
|
return;
|
|
@@ -631,25 +971,53 @@ export class CopilotCliClient {
|
|
|
631
971
|
if (state.sessionCwd === nextCwd) {
|
|
632
972
|
return;
|
|
633
973
|
}
|
|
634
|
-
|
|
635
|
-
|
|
974
|
+
if (state.sessionHasBackgroundAgents || state.sessionHasPendingAutonomousTurn) {
|
|
975
|
+
this.logger?.debug('keeping Copilot session cwd while background delivery is active', {
|
|
976
|
+
channelId: state.channelId,
|
|
977
|
+
sessionCwd: state.sessionCwd,
|
|
978
|
+
requestedCwd: nextCwd,
|
|
979
|
+
});
|
|
980
|
+
state.cwd = state.sessionCwd;
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
this.logger?.debug('recycling Copilot session after cwd changed', {
|
|
984
|
+
channelId: state.channelId,
|
|
636
985
|
previousCwd: state.sessionCwd,
|
|
637
986
|
nextCwd,
|
|
638
987
|
});
|
|
639
988
|
state.session = undefined;
|
|
640
989
|
state.sessionCwd = undefined;
|
|
990
|
+
state.sessionHasBackgroundAgents = false;
|
|
991
|
+
state.sessionHasPendingAutonomousTurn = false;
|
|
641
992
|
this.clearIdleTimer(state);
|
|
642
993
|
if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
|
|
643
|
-
await this.clearPersistedSessionBestEffort(channelId);
|
|
994
|
+
await this.clearPersistedSessionBestEffort(state.channelId, session.sessionId);
|
|
644
995
|
}
|
|
645
996
|
this.closeSession(session);
|
|
646
997
|
}
|
|
647
|
-
async runTurn(session, prompt, options) {
|
|
998
|
+
async runTurn(session, prompt, options, onBackgroundAgentStarted) {
|
|
648
999
|
const promptPromise = this.raceWithFatal(session.prompt(prompt));
|
|
649
1000
|
const promptFailure = new Promise((_, reject) => {
|
|
650
1001
|
void promptPromise.catch((error) => reject(markSessionTainted(error)));
|
|
651
1002
|
});
|
|
652
1003
|
const collector = new AcpProgressCollector(options?.onProgress);
|
|
1004
|
+
const backgroundAgentLaunches = new BackgroundAgentLaunchTracker();
|
|
1005
|
+
let backgroundAgentStarted = false;
|
|
1006
|
+
let backgroundRoutePromoted = false;
|
|
1007
|
+
const promoteBackgroundRoute = () => {
|
|
1008
|
+
if (backgroundRoutePromoted) {
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
backgroundRoutePromoted = true;
|
|
1012
|
+
onBackgroundAgentStarted?.();
|
|
1013
|
+
};
|
|
1014
|
+
const noteBackgroundAgentStarted = () => {
|
|
1015
|
+
if (backgroundAgentStarted) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
backgroundAgentStarted = true;
|
|
1019
|
+
promoteBackgroundRoute();
|
|
1020
|
+
};
|
|
653
1021
|
for (;;) {
|
|
654
1022
|
let update;
|
|
655
1023
|
try {
|
|
@@ -670,17 +1038,27 @@ export class CopilotCliClient {
|
|
|
670
1038
|
throw markSessionTainted(error);
|
|
671
1039
|
}
|
|
672
1040
|
if (response.stopReason === 'cancelled') {
|
|
673
|
-
throw new ProviderTurnCancelledError('Copilot
|
|
1041
|
+
throw new ProviderTurnCancelledError('Copilot turn cancelled');
|
|
674
1042
|
}
|
|
675
1043
|
if (response.stopReason !== 'end_turn') {
|
|
676
|
-
throw new Error(`Copilot
|
|
1044
|
+
throw new Error(`Copilot turn stopped with stopReason "${response.stopReason}"`);
|
|
1045
|
+
}
|
|
1046
|
+
if (isCopilotSdkSession(session) && await session.hasBackgroundAgents()) {
|
|
1047
|
+
backgroundAgentStarted = true;
|
|
1048
|
+
promoteBackgroundRoute();
|
|
677
1049
|
}
|
|
678
1050
|
const output = collector.getFinalText();
|
|
679
1051
|
if (!output) {
|
|
680
|
-
|
|
1052
|
+
if (backgroundAgentStarted) {
|
|
1053
|
+
return 'Background worker started.';
|
|
1054
|
+
}
|
|
1055
|
+
throw new Error('Copilot returned empty output');
|
|
681
1056
|
}
|
|
682
1057
|
return output;
|
|
683
1058
|
}
|
|
1059
|
+
if (!isCopilotSdkSession(session) && backgroundAgentLaunches.consume(update)) {
|
|
1060
|
+
noteBackgroundAgentStarted();
|
|
1061
|
+
}
|
|
684
1062
|
collector.consume(update);
|
|
685
1063
|
}
|
|
686
1064
|
}
|
|
@@ -691,7 +1069,7 @@ export class CopilotCliClient {
|
|
|
691
1069
|
return Promise.race([promise, this.fatalPromise]);
|
|
692
1070
|
}
|
|
693
1071
|
handlePermissionRequest(params) {
|
|
694
|
-
const
|
|
1072
|
+
const lease = this.sessionAuthorizationLeases.get(params.sessionId);
|
|
695
1073
|
return resolveCopilotPermissionResponse({
|
|
696
1074
|
gateEnabled: this.permissionPolicy.gateEnabled ?? false,
|
|
697
1075
|
policyMode: this.permissionPolicy.policyMode ?? 'audit-only',
|
|
@@ -699,8 +1077,25 @@ export class CopilotCliClient {
|
|
|
699
1077
|
logger: this.logger,
|
|
700
1078
|
auditSink: this.permissionPolicy.auditSink,
|
|
701
1079
|
agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
|
|
702
|
-
channelId:
|
|
703
|
-
|
|
1080
|
+
channelId: lease?.channelId,
|
|
1081
|
+
sessionBound: lease !== undefined,
|
|
1082
|
+
toolPermissionMode: lease?.toolPermissionMode ?? 'default',
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
handleSdkPermissionRequest(request, sessionId) {
|
|
1086
|
+
const lease = this.sessionAuthorizationLeases.get(sessionId);
|
|
1087
|
+
return resolveCopilotSdkPermissionResponse({
|
|
1088
|
+
gateEnabled: this.permissionPolicy.gateEnabled ?? false,
|
|
1089
|
+
policyMode: this.permissionPolicy.policyMode ?? 'audit-only',
|
|
1090
|
+
request,
|
|
1091
|
+
sessionId,
|
|
1092
|
+
logger: this.logger,
|
|
1093
|
+
auditSink: this.permissionPolicy.auditSink,
|
|
1094
|
+
auditSurface: 'copilot-sdk-permission',
|
|
1095
|
+
agentId: this.resolveSessionStoreAgentId()?.trim() || undefined,
|
|
1096
|
+
channelId: lease?.channelId,
|
|
1097
|
+
sessionBound: lease !== undefined,
|
|
1098
|
+
toolPermissionMode: lease?.toolPermissionMode ?? 'default',
|
|
704
1099
|
});
|
|
705
1100
|
}
|
|
706
1101
|
failAll(error) {
|
|
@@ -710,37 +1105,48 @@ export class CopilotCliClient {
|
|
|
710
1105
|
this.fatalError = error;
|
|
711
1106
|
this.rejectFatalPromise(error);
|
|
712
1107
|
this.idleBackendShutdown.cancel();
|
|
713
|
-
for (const [
|
|
1108
|
+
for (const [routingKey, state] of this.channels.entries()) {
|
|
714
1109
|
this.clearIdleTimer(state);
|
|
715
1110
|
state.activeTurn?.reject(error);
|
|
716
1111
|
for (const turn of state.queue) {
|
|
717
1112
|
turn.reject(error);
|
|
718
1113
|
}
|
|
719
1114
|
state.queue.length = 0;
|
|
720
|
-
|
|
721
|
-
|
|
1115
|
+
const session = state.session;
|
|
1116
|
+
if (session) {
|
|
1117
|
+
this.releaseWorkerRoute(state, session.sessionId);
|
|
1118
|
+
this.closeSession(session);
|
|
1119
|
+
}
|
|
1120
|
+
else {
|
|
1121
|
+
this.releaseWorkerRoute(state);
|
|
722
1122
|
}
|
|
723
1123
|
state.session = undefined;
|
|
724
1124
|
state.sessionCwd = undefined;
|
|
1125
|
+
state.sessionHasBackgroundAgents = false;
|
|
1126
|
+
state.sessionHasPendingAutonomousTurn = false;
|
|
725
1127
|
state.sessionPromise = undefined;
|
|
726
1128
|
state.activeTurn = undefined;
|
|
727
1129
|
if (!state.processing) {
|
|
728
|
-
this.channels.delete(
|
|
1130
|
+
this.channels.delete(routingKey);
|
|
729
1131
|
}
|
|
730
1132
|
}
|
|
731
1133
|
this.channels.clear();
|
|
1134
|
+
this.channelWorkerRoutes.clear();
|
|
732
1135
|
this.shutdownPromise = this.shutdownBackend(error);
|
|
733
1136
|
}
|
|
734
|
-
invalidateSession(
|
|
1137
|
+
invalidateSession(state, session) {
|
|
735
1138
|
this.clearIdleTimer(state);
|
|
736
1139
|
if (state.session === session) {
|
|
737
1140
|
state.session = undefined;
|
|
738
1141
|
state.sessionCwd = undefined;
|
|
1142
|
+
state.sessionHasBackgroundAgents = false;
|
|
1143
|
+
state.sessionHasPendingAutonomousTurn = false;
|
|
739
1144
|
}
|
|
740
|
-
this.
|
|
1145
|
+
this.releaseWorkerRoute(state, session.sessionId);
|
|
1146
|
+
this.logger?.debug('discarding tainted Copilot session', { channelId: state.channelId });
|
|
741
1147
|
this.closeSession(session);
|
|
742
1148
|
if (!this.disposing && !this.backendClosed && state.sessionPersistence === 'persistent') {
|
|
743
|
-
void this.clearPersistedSessionBestEffort(channelId);
|
|
1149
|
+
void this.clearPersistedSessionBestEffort(state.channelId, session.sessionId);
|
|
744
1150
|
}
|
|
745
1151
|
}
|
|
746
1152
|
closeSession(session) {
|
|
@@ -748,26 +1154,79 @@ export class CopilotCliClient {
|
|
|
748
1154
|
return;
|
|
749
1155
|
}
|
|
750
1156
|
this.closingSessions.add(session);
|
|
751
|
-
|
|
1157
|
+
if (isCopilotSdkSession(session)) {
|
|
1158
|
+
let trackedClosePromise;
|
|
1159
|
+
trackedClosePromise = this.waitForSessionClose(session.close())
|
|
1160
|
+
.then((closed) => {
|
|
1161
|
+
if (closed) {
|
|
1162
|
+
this.sessionAuthorizationLeases.delete(session.sessionId);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (!this.backendClosed && !this.backendShutdownInProgress) {
|
|
1166
|
+
this.logger?.debug('Copilot SDK session close timed out; recycling backend', {
|
|
1167
|
+
sessionId: session.sessionId,
|
|
1168
|
+
timeoutMs: this.runtime.sessionCloseTimeoutMs,
|
|
1169
|
+
});
|
|
1170
|
+
this.markSessionOrphaned(session);
|
|
1171
|
+
}
|
|
1172
|
+
})
|
|
1173
|
+
.catch(() => {
|
|
1174
|
+
if (!this.backendClosed && !this.backendShutdownInProgress) {
|
|
1175
|
+
this.markSessionOrphaned(session);
|
|
1176
|
+
}
|
|
1177
|
+
})
|
|
1178
|
+
.finally(() => {
|
|
1179
|
+
this.pendingSessionCloses.delete(trackedClosePromise);
|
|
1180
|
+
});
|
|
1181
|
+
this.pendingSessionCloses.add(trackedClosePromise);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
session.dispose();
|
|
1185
|
+
if (!this.sessionCapabilities.closeSession || !this.connection) {
|
|
1186
|
+
this.markSessionOrphaned(session);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const agent = this.connection.agent;
|
|
752
1190
|
const closeMethod = this.runtime.methods.agent.session?.close;
|
|
753
|
-
const closePromise = typeof agent
|
|
1191
|
+
const closePromise = typeof agent.closeSession === 'function'
|
|
754
1192
|
? agent.closeSession({ sessionId: session.sessionId })
|
|
755
1193
|
: closeMethod
|
|
756
|
-
? this.connection
|
|
1194
|
+
? this.connection.agent.request(closeMethod, { sessionId: session.sessionId })
|
|
757
1195
|
: undefined;
|
|
758
|
-
session.dispose();
|
|
759
1196
|
if (!closePromise) {
|
|
1197
|
+
this.markSessionOrphaned(session);
|
|
760
1198
|
return;
|
|
761
1199
|
}
|
|
762
1200
|
let trackedClosePromise;
|
|
763
|
-
trackedClosePromise =
|
|
764
|
-
.then(() =>
|
|
765
|
-
|
|
1201
|
+
trackedClosePromise = this.waitForSessionClose(closePromise)
|
|
1202
|
+
.then((closed) => {
|
|
1203
|
+
if (closed) {
|
|
1204
|
+
this.sessionAuthorizationLeases.delete(session.sessionId);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (!this.backendClosed && !this.backendShutdownInProgress) {
|
|
1208
|
+
this.logger?.debug('Copilot session close timed out; recycling backend', {
|
|
1209
|
+
sessionId: session.sessionId,
|
|
1210
|
+
timeoutMs: this.runtime.sessionCloseTimeoutMs,
|
|
1211
|
+
});
|
|
1212
|
+
this.markSessionOrphaned(session);
|
|
1213
|
+
}
|
|
1214
|
+
})
|
|
1215
|
+
.catch(() => {
|
|
1216
|
+
if (!this.backendClosed && !this.backendShutdownInProgress) {
|
|
1217
|
+
this.markSessionOrphaned(session);
|
|
1218
|
+
}
|
|
1219
|
+
})
|
|
766
1220
|
.finally(() => {
|
|
767
1221
|
this.pendingSessionCloses.delete(trackedClosePromise);
|
|
768
1222
|
});
|
|
769
1223
|
this.pendingSessionCloses.add(trackedClosePromise);
|
|
770
1224
|
}
|
|
1225
|
+
markSessionOrphaned(session) {
|
|
1226
|
+
this.orphanedSessions.add(session);
|
|
1227
|
+
this.backendRecycleRequired = true;
|
|
1228
|
+
this.reconcileBackendLifecycle();
|
|
1229
|
+
}
|
|
771
1230
|
rejectQueuedTurnsAfterSessionTaint(state, error) {
|
|
772
1231
|
if (state.queue.length === 0) {
|
|
773
1232
|
return;
|
|
@@ -788,16 +1247,45 @@ export class CopilotCliClient {
|
|
|
788
1247
|
clearTimeout(state.idleTimer);
|
|
789
1248
|
state.idleTimer = undefined;
|
|
790
1249
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1250
|
+
bindSessionAuthorization(session, state) {
|
|
1251
|
+
this.sessionAuthorizationLeases.set(session.sessionId, {
|
|
1252
|
+
channelId: state.channelId,
|
|
1253
|
+
toolPermissionMode: state.toolPermissionMode ?? 'default',
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
promoteWorkerRoute(state, session) {
|
|
1257
|
+
state.sessionHasBackgroundAgents = true;
|
|
1258
|
+
this.channelWorkerRoutes.set(state.channelId, {
|
|
1259
|
+
phase: 'promoted',
|
|
1260
|
+
state,
|
|
1261
|
+
});
|
|
1262
|
+
state.sessionPersistence = 'persistent';
|
|
1263
|
+
void this.persistSessionBestEffort(state, session.sessionId, state.sessionCwd ?? state.cwd ?? this.runtime.cwd, isCopilotSdkSession(session) ? session.backgroundTaskExecutionState() : undefined);
|
|
1264
|
+
}
|
|
1265
|
+
releaseWorkerRoute(state, sessionId) {
|
|
1266
|
+
const route = this.channelWorkerRoutes.get(state.channelId);
|
|
1267
|
+
if (route?.state !== state) {
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
this.channelWorkerRoutes.delete(state.channelId);
|
|
1271
|
+
if (route.phase === 'promoted' && sessionId) {
|
|
1272
|
+
void this.clearPersistedSessionBestEffort(state.channelId, sessionId);
|
|
796
1273
|
}
|
|
797
|
-
return undefined;
|
|
798
1274
|
}
|
|
799
|
-
|
|
800
|
-
|
|
1275
|
+
releaseInactiveWorkerRoute(state) {
|
|
1276
|
+
const route = this.channelWorkerRoutes.get(state.channelId);
|
|
1277
|
+
if (route?.state !== state || route.phase !== 'promoted') {
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (state.routingKey === state.channelId) {
|
|
1281
|
+
this.releaseWorkerRoute(state);
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
state.sessionPersistence = 'ephemeral';
|
|
1285
|
+
this.releaseWorkerRoute(state, state.session?.sessionId);
|
|
1286
|
+
}
|
|
1287
|
+
reconcileIdleChannelState(state) {
|
|
1288
|
+
if (this.channels.get(state.routingKey) !== state) {
|
|
801
1289
|
return;
|
|
802
1290
|
}
|
|
803
1291
|
if (this.disposing || this.fatalError || this.backendClosed) {
|
|
@@ -810,22 +1298,29 @@ export class CopilotCliClient {
|
|
|
810
1298
|
}
|
|
811
1299
|
if (!state.session) {
|
|
812
1300
|
this.clearIdleTimer(state);
|
|
813
|
-
this.
|
|
1301
|
+
this.releaseWorkerRoute(state);
|
|
1302
|
+
this.channels.delete(state.routingKey);
|
|
814
1303
|
return;
|
|
815
1304
|
}
|
|
816
|
-
if (state.
|
|
1305
|
+
if (!state.sessionHasBackgroundAgents && !state.sessionHasPendingAutonomousTurn) {
|
|
1306
|
+
this.releaseInactiveWorkerRoute(state);
|
|
1307
|
+
}
|
|
1308
|
+
if (state.sessionPersistence === 'ephemeral'
|
|
1309
|
+
&& !state.sessionHasBackgroundAgents
|
|
1310
|
+
&& !state.sessionHasPendingAutonomousTurn) {
|
|
817
1311
|
this.clearIdleTimer(state);
|
|
818
1312
|
const session = state.session;
|
|
819
1313
|
state.session = undefined;
|
|
820
1314
|
state.sessionCwd = undefined;
|
|
1315
|
+
this.releaseWorkerRoute(state);
|
|
821
1316
|
this.closeSession(session);
|
|
822
|
-
this.channels.delete(
|
|
1317
|
+
this.channels.delete(state.routingKey);
|
|
823
1318
|
return;
|
|
824
1319
|
}
|
|
825
1320
|
this.clearIdleTimer(state);
|
|
826
1321
|
const generation = state.idleTimerGeneration;
|
|
827
1322
|
state.idleTimer = setTimeout(() => {
|
|
828
|
-
this.evictIdleChannel(
|
|
1323
|
+
this.evictIdleChannel(state, generation);
|
|
829
1324
|
}, this.runtime.idleSessionTtlMs);
|
|
830
1325
|
}
|
|
831
1326
|
async ensureSessionStoreLoaded() {
|
|
@@ -860,42 +1355,67 @@ export class CopilotCliClient {
|
|
|
860
1355
|
return undefined;
|
|
861
1356
|
}
|
|
862
1357
|
if (!record.cwd || record.cwd !== cwd) {
|
|
863
|
-
await this.clearPersistedSessionBestEffort(channelId);
|
|
1358
|
+
await this.clearPersistedSessionBestEffort(channelId, record.sessionId);
|
|
864
1359
|
return undefined;
|
|
865
1360
|
}
|
|
866
1361
|
return record.sessionId;
|
|
867
1362
|
}
|
|
868
|
-
|
|
1363
|
+
isPersistentRouteOwner(state) {
|
|
1364
|
+
const workerRoute = this.channelWorkerRoutes.get(state.channelId);
|
|
1365
|
+
if (workerRoute) {
|
|
1366
|
+
return workerRoute.state === state && state.sessionPersistence === 'persistent';
|
|
1367
|
+
}
|
|
1368
|
+
return this.channels.get(state.channelId) === state;
|
|
1369
|
+
}
|
|
1370
|
+
async persistSession(state, sessionId, cwd, backgroundTaskExecutions) {
|
|
869
1371
|
await this.ensureSessionStoreLoaded();
|
|
870
|
-
this.
|
|
1372
|
+
if (!this.isPersistentRouteOwner(state) || state.session?.sessionId !== sessionId) {
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const existingRecord = this.persistedSessions.get(state.channelId);
|
|
1376
|
+
const retainedBackgroundTaskExecutions = backgroundTaskExecutions ??
|
|
1377
|
+
(existingRecord?.sessionId === sessionId
|
|
1378
|
+
? existingRecord.backgroundTaskExecutions
|
|
1379
|
+
: undefined);
|
|
1380
|
+
this.persistedSessions.set(state.channelId, {
|
|
1381
|
+
sessionId,
|
|
1382
|
+
cwd,
|
|
1383
|
+
...(retainedBackgroundTaskExecutions
|
|
1384
|
+
? { backgroundTaskExecutions: { ...retainedBackgroundTaskExecutions } }
|
|
1385
|
+
: {}),
|
|
1386
|
+
});
|
|
871
1387
|
await this.flushSessionStore();
|
|
872
1388
|
}
|
|
873
|
-
async persistSessionBestEffort(
|
|
1389
|
+
async persistSessionBestEffort(state, sessionId, cwd, backgroundTaskExecutions) {
|
|
874
1390
|
await this.trackSessionStoreOperation((async () => {
|
|
875
1391
|
try {
|
|
876
|
-
await this.persistSession(
|
|
1392
|
+
await this.persistSession(state, sessionId, cwd, backgroundTaskExecutions);
|
|
877
1393
|
}
|
|
878
1394
|
catch (error) {
|
|
879
1395
|
this.logger.error('failed to persist Copilot session map; keeping reply delivery', {
|
|
880
1396
|
agentId: this.loadedSessionStoreAgentId,
|
|
881
|
-
channelId,
|
|
1397
|
+
channelId: state.channelId,
|
|
882
1398
|
sessionId,
|
|
883
1399
|
error: summarizeError(error),
|
|
884
1400
|
});
|
|
885
1401
|
}
|
|
886
1402
|
})());
|
|
887
1403
|
}
|
|
888
|
-
async clearPersistedSession(channelId) {
|
|
1404
|
+
async clearPersistedSession(channelId, expectedSessionId) {
|
|
889
1405
|
await this.ensureSessionStoreLoaded();
|
|
1406
|
+
if (expectedSessionId
|
|
1407
|
+
&& this.persistedSessions.get(channelId)?.sessionId !== expectedSessionId) {
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
890
1410
|
if (!this.persistedSessions.delete(channelId)) {
|
|
891
1411
|
return;
|
|
892
1412
|
}
|
|
893
1413
|
await this.flushSessionStore();
|
|
894
1414
|
}
|
|
895
|
-
async clearPersistedSessionBestEffort(channelId) {
|
|
1415
|
+
async clearPersistedSessionBestEffort(channelId, expectedSessionId) {
|
|
896
1416
|
await this.trackSessionStoreOperation((async () => {
|
|
897
1417
|
try {
|
|
898
|
-
await this.clearPersistedSession(channelId);
|
|
1418
|
+
await this.clearPersistedSession(channelId, expectedSessionId);
|
|
899
1419
|
}
|
|
900
1420
|
catch (error) {
|
|
901
1421
|
this.logger.error('failed to clear Copilot session map; retrying in-memory only', {
|
|
@@ -926,89 +1446,178 @@ export class CopilotCliClient {
|
|
|
926
1446
|
this.pendingSessionStoreOperations.add(tracked);
|
|
927
1447
|
return tracked;
|
|
928
1448
|
}
|
|
929
|
-
evictIdleChannel(
|
|
1449
|
+
evictIdleChannel(state, generation) {
|
|
930
1450
|
if (this.disposing || this.fatalError || this.backendClosed) {
|
|
931
1451
|
return;
|
|
932
1452
|
}
|
|
933
|
-
if (this.channels.get(
|
|
1453
|
+
if (this.channels.get(state.routingKey) !== state
|
|
1454
|
+
|| state.idleTimerGeneration !== generation) {
|
|
934
1455
|
return;
|
|
935
1456
|
}
|
|
936
1457
|
state.idleTimer = undefined;
|
|
937
|
-
if (state.activeTurn
|
|
1458
|
+
if (state.activeTurn
|
|
1459
|
+
|| state.queue.length > 0
|
|
1460
|
+
|| state.sessionPromise
|
|
1461
|
+
|| state.processing
|
|
1462
|
+
|| state.sessionHasPendingAutonomousTurn) {
|
|
938
1463
|
return;
|
|
939
1464
|
}
|
|
940
1465
|
const session = state.session;
|
|
941
1466
|
state.session = undefined;
|
|
942
1467
|
state.sessionCwd = undefined;
|
|
1468
|
+
state.sessionHasBackgroundAgents = false;
|
|
1469
|
+
state.sessionHasPendingAutonomousTurn = false;
|
|
943
1470
|
if (session) {
|
|
944
|
-
this.logger?.debug('evicting idle Copilot
|
|
1471
|
+
this.logger?.debug('evicting idle Copilot session', { channelId: state.channelId });
|
|
1472
|
+
this.releaseWorkerRoute(state, session.sessionId);
|
|
945
1473
|
this.closeSession(session);
|
|
946
1474
|
}
|
|
947
|
-
|
|
1475
|
+
else {
|
|
1476
|
+
this.releaseWorkerRoute(state);
|
|
1477
|
+
}
|
|
1478
|
+
this.channels.delete(state.routingKey);
|
|
1479
|
+
this.reconcileBackendLifecycle();
|
|
948
1480
|
}
|
|
949
1481
|
async shutdownBackend(error) {
|
|
950
1482
|
if (this.backendClosed) {
|
|
951
1483
|
return;
|
|
952
1484
|
}
|
|
953
1485
|
this.backendClosed = true;
|
|
954
|
-
await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
|
|
1486
|
+
const terminated = await this.closeBackendProcess(error, this.runtime.shutdownGracePeriodMs);
|
|
1487
|
+
if (terminated) {
|
|
1488
|
+
this.releaseBackendAuthorizationLeases();
|
|
1489
|
+
}
|
|
955
1490
|
}
|
|
956
1491
|
/**
|
|
957
1492
|
* Backend teardown without the fatal latch: the client stays usable and
|
|
958
1493
|
* `ensureStarted()` spawns a replacement process on the next turn. Only safe
|
|
959
|
-
* while `isBackendIdle()` holds, because every channel's live
|
|
1494
|
+
* while `isBackendIdle()` holds, because every channel's live session is
|
|
960
1495
|
* dropped here and no turn is ever replayed onto its successor.
|
|
961
1496
|
*/
|
|
962
1497
|
async shutdownIdleBackend() {
|
|
1498
|
+
if (this.backendShutdownInProgress) {
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
this.backendShutdownInProgress = true;
|
|
963
1502
|
// Detach the running backend before any signal reaches it, so its own exit
|
|
964
1503
|
// and connection-closed handlers read this teardown as deliberate instead
|
|
965
1504
|
// of latching a fatal error on a client that stays usable.
|
|
966
1505
|
this.backendGeneration += 1;
|
|
967
1506
|
const error = new Error(IDLE_BACKEND_STOPPED_MESSAGE);
|
|
968
1507
|
const sessions = [];
|
|
969
|
-
for (const [
|
|
1508
|
+
for (const [routingKey, state] of [...this.channels.entries()]) {
|
|
970
1509
|
this.clearIdleTimer(state);
|
|
971
1510
|
if (state.session) {
|
|
972
1511
|
sessions.push(state.session);
|
|
1512
|
+
this.releaseWorkerRoute(state, state.session.sessionId);
|
|
1513
|
+
}
|
|
1514
|
+
else {
|
|
1515
|
+
this.releaseWorkerRoute(state);
|
|
973
1516
|
}
|
|
974
1517
|
state.session = undefined;
|
|
975
1518
|
state.sessionCwd = undefined;
|
|
976
|
-
|
|
1519
|
+
state.sessionHasBackgroundAgents = false;
|
|
1520
|
+
state.sessionHasPendingAutonomousTurn = false;
|
|
1521
|
+
this.channels.delete(routingKey);
|
|
977
1522
|
}
|
|
978
|
-
this.
|
|
1523
|
+
this.channelWorkerRoutes.clear();
|
|
1524
|
+
sessions.push(...this.orphanedSessions);
|
|
1525
|
+
this.logger?.debug('stopping idle Copilot runtime backend', {
|
|
979
1526
|
channels: sessions.length,
|
|
980
1527
|
idleShutdownMs: this.runtime.idleBackendShutdownMs,
|
|
981
1528
|
});
|
|
982
1529
|
for (const session of sessions) {
|
|
983
1530
|
this.closeSession(session);
|
|
984
1531
|
}
|
|
985
|
-
const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).
|
|
1532
|
+
const idleShutdownPromise = this.closeBackendProcess(error, this.runtime.idleShutdownGracePeriodMs).then((terminated) => {
|
|
1533
|
+
if (!terminated) {
|
|
1534
|
+
const terminationError = new Error('Copilot runtime backend termination could not be confirmed after SIGKILL');
|
|
1535
|
+
this.backendClosed = true;
|
|
1536
|
+
this.fatalError = terminationError;
|
|
1537
|
+
this.rejectFatalPromise(terminationError);
|
|
1538
|
+
throw terminationError;
|
|
1539
|
+
}
|
|
1540
|
+
this.releaseBackendAuthorizationLeases();
|
|
1541
|
+
}).finally(() => {
|
|
986
1542
|
this.startPromise = undefined;
|
|
987
1543
|
this.childExitPromise = undefined;
|
|
1544
|
+
this.sdkClient = undefined;
|
|
988
1545
|
this.sessionCapabilities = DEFAULT_SESSION_CAPABILITIES;
|
|
1546
|
+
this.backendRecycleRequired = this.fatalError !== null;
|
|
1547
|
+
this.backendShutdownInProgress = false;
|
|
989
1548
|
this.idleShutdownPromise = undefined;
|
|
990
1549
|
});
|
|
991
1550
|
this.idleShutdownPromise = idleShutdownPromise;
|
|
992
1551
|
await idleShutdownPromise;
|
|
993
1552
|
}
|
|
994
1553
|
isBackendIdle() {
|
|
995
|
-
if (this.disposing
|
|
1554
|
+
if (this.disposing
|
|
1555
|
+
|| this.fatalError
|
|
1556
|
+
|| this.backendClosed
|
|
1557
|
+
|| this.idleShutdownPromise
|
|
1558
|
+
|| this.backendShutdownInProgress) {
|
|
996
1559
|
return false;
|
|
997
1560
|
}
|
|
998
|
-
if (!this.child) {
|
|
1561
|
+
if (!this.child && !this.sdkClient) {
|
|
999
1562
|
return false;
|
|
1000
1563
|
}
|
|
1001
1564
|
if (this.pendingSessionStarts.size > 0) {
|
|
1002
1565
|
return false;
|
|
1003
1566
|
}
|
|
1004
1567
|
for (const state of this.channels.values()) {
|
|
1005
|
-
|
|
1568
|
+
// The flag is deliberately sticky until that channel reaches its own
|
|
1569
|
+
// cleanup boundary. Recycling sooner would erase the backend-owned
|
|
1570
|
+
// read_agent/write_agent registry for another channel's worker.
|
|
1571
|
+
if (state.activeTurn
|
|
1572
|
+
|| state.queue.length > 0
|
|
1573
|
+
|| state.sessionPromise
|
|
1574
|
+
|| state.processing
|
|
1575
|
+
|| state.sessionHasBackgroundAgents
|
|
1576
|
+
|| state.sessionHasPendingAutonomousTurn) {
|
|
1006
1577
|
return false;
|
|
1007
1578
|
}
|
|
1008
1579
|
}
|
|
1009
1580
|
return true;
|
|
1010
1581
|
}
|
|
1582
|
+
reconcileBackendLifecycle() {
|
|
1583
|
+
if (this.backendRecycleRequired && this.isBackendIdle()) {
|
|
1584
|
+
if (!this.idleShutdownPromise) {
|
|
1585
|
+
void this.shutdownIdleBackend().catch((error) => {
|
|
1586
|
+
this.logger.error('failed to recycle Copilot runtime backend after session cleanup', {
|
|
1587
|
+
error: summarizeError(error),
|
|
1588
|
+
});
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
this.idleBackendShutdown.reconcile();
|
|
1594
|
+
}
|
|
1595
|
+
releaseBackendAuthorizationLeases() {
|
|
1596
|
+
this.sessionAuthorizationLeases.clear();
|
|
1597
|
+
this.orphanedSessions.clear();
|
|
1598
|
+
}
|
|
1011
1599
|
async closeBackendProcess(error, gracePeriodMs) {
|
|
1600
|
+
if (this.sdkClient) {
|
|
1601
|
+
const sdkClient = this.sdkClient;
|
|
1602
|
+
this.sdkClient = undefined;
|
|
1603
|
+
await this.waitForPendingSessionStarts();
|
|
1604
|
+
await this.waitForPendingSessionCloses();
|
|
1605
|
+
const stopPromise = sdkClient.stop();
|
|
1606
|
+
const stopResult = await Promise.race([
|
|
1607
|
+
stopPromise.then((errors) => ({ completed: true, errors })),
|
|
1608
|
+
new Promise((resolvePromise) => {
|
|
1609
|
+
setTimeout(() => resolvePromise({ completed: false }), gracePeriodMs);
|
|
1610
|
+
}),
|
|
1611
|
+
]);
|
|
1612
|
+
if (!stopResult.completed) {
|
|
1613
|
+
await sdkClient.forceStop();
|
|
1614
|
+
return true;
|
|
1615
|
+
}
|
|
1616
|
+
if (stopResult.errors.length > 0) {
|
|
1617
|
+
throw new AggregateError(stopResult.errors, error.message);
|
|
1618
|
+
}
|
|
1619
|
+
return true;
|
|
1620
|
+
}
|
|
1012
1621
|
const connection = this.connection;
|
|
1013
1622
|
const child = this.child;
|
|
1014
1623
|
await this.waitForPendingSessionStarts();
|
|
@@ -1017,7 +1626,7 @@ export class CopilotCliClient {
|
|
|
1017
1626
|
this.connection = undefined;
|
|
1018
1627
|
if (!child) {
|
|
1019
1628
|
this.child = undefined;
|
|
1020
|
-
return;
|
|
1629
|
+
return true;
|
|
1021
1630
|
}
|
|
1022
1631
|
const childExitPromise = this.childExitPromise ?? Promise.resolve();
|
|
1023
1632
|
this.logger?.debug('sending SIGTERM to Copilot ACP process', { gracePeriodMs });
|
|
@@ -1026,12 +1635,13 @@ export class CopilotCliClient {
|
|
|
1026
1635
|
if (exitedAfterTerm) {
|
|
1027
1636
|
this.logger?.debug('Copilot ACP process exited after SIGTERM');
|
|
1028
1637
|
this.child = undefined;
|
|
1029
|
-
return;
|
|
1638
|
+
return true;
|
|
1030
1639
|
}
|
|
1031
1640
|
this.logger?.debug('sending SIGKILL to Copilot ACP process after SIGTERM grace timeout');
|
|
1032
1641
|
child.kill('SIGKILL');
|
|
1033
|
-
await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
|
|
1642
|
+
const exitedAfterKill = await this.waitForChildExit(childExitPromise, this.runtime.shutdownForceKillWaitMs);
|
|
1034
1643
|
this.child = undefined;
|
|
1644
|
+
return exitedAfterKill;
|
|
1035
1645
|
}
|
|
1036
1646
|
async waitForPendingSessionStarts() {
|
|
1037
1647
|
if (this.pendingSessionStarts.size === 0) {
|
|
@@ -1055,6 +1665,25 @@ export class CopilotCliClient {
|
|
|
1055
1665
|
}),
|
|
1056
1666
|
]);
|
|
1057
1667
|
}
|
|
1668
|
+
async waitForSessionClose(closePromise) {
|
|
1669
|
+
if (this.runtime.sessionCloseTimeoutMs <= 0) {
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
let timeout;
|
|
1673
|
+
try {
|
|
1674
|
+
return await Promise.race([
|
|
1675
|
+
Promise.resolve(closePromise).then(() => true),
|
|
1676
|
+
new Promise((resolve) => {
|
|
1677
|
+
timeout = setTimeout(() => resolve(false), this.runtime.sessionCloseTimeoutMs);
|
|
1678
|
+
}),
|
|
1679
|
+
]);
|
|
1680
|
+
}
|
|
1681
|
+
finally {
|
|
1682
|
+
if (timeout) {
|
|
1683
|
+
clearTimeout(timeout);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1058
1687
|
async waitForChildExit(childExitPromise, timeoutMs) {
|
|
1059
1688
|
if (timeoutMs <= 0) {
|
|
1060
1689
|
return false;
|