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