Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/agentHostStateManager.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { RunOnceScheduler } from '../../../base/common/async.js';
7
import { Emitter, Event } from '../../../base/common/event.js';
8
import { Disposable } from '../../../base/common/lifecycle.js';
9
import { ILogService } from '../../log/common/log.js';
10
import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, RootAction, StateAction, TerminalAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js';
11
import type { IStateSnapshot } from '../common/state/sessionProtocol.js';
12
import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js';
13
import { createRootState, createSessionState, SessionLifecycle, type RootState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js';
14
import { IPermissionsValue, platformRootSchema } from '../common/agentHostSchema.js';
15
import { SessionConfigKey } from '../common/sessionConfigKeys.js';
16
17
/**
18
* Server-side state manager for the sessions process protocol.
19
*
20
* Maintains the authoritative state tree (root + per-session), applies actions
21
* through pure reducers, assigns monotonic sequence numbers, and emits
22
* {@link ActionEnvelope}s for subscribed clients.
23
*/
24
export class AgentHostStateManager extends Disposable {
25
26
private _serverSeq = 0;
27
28
private _rootState: RootState;
29
private readonly _sessionStates = new Map<string, SessionState>();
30
31
/** Tracks which session URI each active turn belongs to, keyed by turnId. */
32
private readonly _activeTurnToSession = new Map<string, string>();
33
34
/** Last summary sent to clients (via sessionAdded or sessionSummaryChanged). */
35
private readonly _lastNotifiedSummaries = new Map<string, SessionSummary>();
36
/** Sessions whose summary changed since the last flush. */
37
private readonly _dirtySummaries = new Set<string>();
38
private readonly _summaryNotifyScheduler = this._register(new RunOnceScheduler(() => this._flushSummaryNotifications(), 100));
39
40
private readonly _onDidEmitEnvelope = this._register(new Emitter<ActionEnvelope>());
41
readonly onDidEmitEnvelope: Event<ActionEnvelope> = this._onDidEmitEnvelope.event;
42
43
private readonly _onDidEmitNotification = this._register(new Emitter<INotification>());
44
readonly onDidEmitNotification: Event<INotification> = this._onDidEmitNotification.event;
45
46
constructor(
47
@ILogService private readonly _logService: ILogService,
48
) {
49
super();
50
this._rootState = createRootState();
51
// Seed the host-level configuration schema + default values so that
52
// RootConfigChanged actions can merge into it, and clients see the
53
// schema immediately upon subscribing to `agenthost:/root`. See
54
// `platformRootSchema` for the set of platform-owned properties.
55
this._rootState = {
56
...this._rootState,
57
config: {
58
schema: platformRootSchema.toProtocol(),
59
values: platformRootSchema.validateOrDefault({}, {
60
[SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue,
61
}),
62
},
63
};
64
}
65
private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`);
66
67
get hasActiveSessions(): boolean {
68
return this._activeTurnToSession.size > 0;
69
}
70
71
// ---- State accessors ----------------------------------------------------
72
73
get rootState(): RootState {
74
return this._rootState;
75
}
76
77
getSessionState(session: URI): SessionState | undefined {
78
return this._sessionStates.get(session);
79
}
80
81
get serverSeq(): number {
82
return this._serverSeq;
83
}
84
85
getSessionUris(): string[] {
86
return [...this._sessionStates.keys()];
87
}
88
89
/**
90
* Returns all session URIs whose keys start with the given prefix.
91
* Used to discover subagent sessions for a given parent.
92
*/
93
getSessionUrisWithPrefix(prefix: string): string[] {
94
const result: string[] = [];
95
for (const key of this._sessionStates.keys()) {
96
if (key.startsWith(prefix)) {
97
result.push(key);
98
}
99
}
100
return result;
101
}
102
103
// ---- Snapshots ----------------------------------------------------------
104
105
/**
106
* Returns a state snapshot for a given resource URI.
107
* The `fromSeq` in the snapshot is the current serverSeq at snapshot time;
108
* the client should process subsequent envelopes with serverSeq > fromSeq.
109
*/
110
getSnapshot(resource: URI): IStateSnapshot | undefined {
111
if (resource === ROOT_STATE_URI) {
112
return {
113
resource,
114
state: this._rootState,
115
fromSeq: this._serverSeq,
116
};
117
}
118
119
const sessionState = this._sessionStates.get(resource);
120
if (!sessionState) {
121
return undefined;
122
}
123
124
return {
125
resource,
126
state: sessionState,
127
fromSeq: this._serverSeq,
128
};
129
}
130
131
// ---- Session lifecycle --------------------------------------------------
132
133
/**
134
* Creates a new session in state with `lifecycle: 'creating'`.
135
* Returns the initial session state.
136
*/
137
createSession(summary: SessionSummary): SessionState {
138
const key = summary.resource;
139
if (this._sessionStates.has(key)) {
140
this._logService.warn(`[AgentHostStateManager] Session already exists: ${key}`);
141
return this._sessionStates.get(key)!;
142
}
143
144
const state = createSessionState(summary);
145
this._sessionStates.set(key, state);
146
147
this._logService.trace(`[AgentHostStateManager] Created session: ${key}`);
148
149
this._lastNotifiedSummaries.set(key, summary);
150
this._onDidEmitNotification.fire({
151
type: NotificationType.SessionAdded,
152
summary,
153
});
154
155
return state;
156
}
157
158
/**
159
* Restores a session from a previous server lifetime into the state manager
160
* with pre-populated turns. The session is created in `ready` lifecycle
161
* state since it already exists on the backend.
162
*
163
* Unlike {@link createSession}, this does NOT emit a `sessionAdded`
164
* notification because the session is already known to clients via
165
* `listSessions`.
166
*/
167
restoreSession(summary: SessionSummary, turns: Turn[]): SessionState {
168
const key = summary.resource;
169
if (this._sessionStates.has(key)) {
170
this._logService.warn(`[AgentHostStateManager] Session already exists (restore): ${key}`);
171
return this._sessionStates.get(key)!;
172
}
173
174
const state: SessionState = {
175
...createSessionState(summary),
176
lifecycle: SessionLifecycle.Ready,
177
turns,
178
};
179
this._sessionStates.set(key, state);
180
this._lastNotifiedSummaries.set(key, summary);
181
182
this._logService.trace(`[AgentHostStateManager] Restored session: ${key} (${turns.length} turns)`);
183
184
return state;
185
}
186
187
/**
188
* Removes a session from in-memory state without emitting a notification.
189
* Use {@link deleteSession} when the session is being permanently deleted
190
* and clients need to be notified.
191
*/
192
removeSession(session: URI): void {
193
const state = this._sessionStates.get(session);
194
if (!state) {
195
return;
196
}
197
198
// Clean up active turn tracking
199
if (state.activeTurn) {
200
this._activeTurnToSession.delete(state.activeTurn.id);
201
}
202
203
this._sessionStates.delete(session);
204
this._lastNotifiedSummaries.delete(session);
205
this._dirtySummaries.delete(session);
206
this._logService.trace(`[AgentHostStateManager] Removed session: ${session}`);
207
}
208
209
/**
210
* Permanently deletes a session from state and emits a
211
* {@link NotificationType.SessionRemoved} notification so that clients
212
* know the session is no longer accessible.
213
*/
214
deleteSession(session: URI): void {
215
this.removeSession(session);
216
this._onDidEmitNotification.fire({
217
type: NotificationType.SessionRemoved,
218
session,
219
});
220
}
221
222
// ---- Session meta -------------------------------------------------------
223
224
/**
225
* Replaces `state._meta` on a session by dispatching a
226
* {@link ActionType.SessionMetaChanged} action so the change flows
227
* through the action envelope (and thus to all live subscribers).
228
*
229
* The full `_meta` object is replaced (not merged) so callers stay in
230
* control of the convention for their own keys; use the `withSessionXxx`
231
* helpers in `sessionState.ts` to combine slots.
232
*/
233
setSessionMeta(session: URI, meta: SessionMeta | undefined): void {
234
this.dispatchServerAction({ type: ActionType.SessionMetaChanged, session, _meta: meta });
235
}
236
237
// ---- Turn tracking ------------------------------------------------------
238
239
/**
240
* Registers a mapping from turnId to session URI so that incoming
241
* provider events (which carry only session URI) can be associated
242
* with the correct active turn.
243
*/
244
getActiveTurnId(session: URI): string | undefined {
245
const state = this._sessionStates.get(session);
246
return state?.activeTurn?.id;
247
}
248
249
// ---- Action dispatch ----------------------------------------------------
250
251
/**
252
* Dispatch a server-originated action (from the agent backend).
253
* The action is applied to state via the reducer and emitted as an
254
* envelope with no origin (server-produced).
255
*/
256
dispatchServerAction(action: StateAction): void {
257
this._applyAndEmit(action, undefined);
258
}
259
260
/**
261
* Dispatch a client-originated action (write-ahead from a renderer).
262
* The action is applied to state and emitted with the client's origin
263
* so the originating client can reconcile.
264
*/
265
dispatchClientAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, origin: ActionOrigin): unknown {
266
return this._applyAndEmit(action, origin);
267
}
268
269
// ---- Internal -----------------------------------------------------------
270
271
private _applyAndEmit(action: StateAction, origin: ActionOrigin | undefined): unknown {
272
let resultingState: unknown = undefined;
273
// Apply to state
274
if (isRootAction(action)) {
275
this._rootState = rootReducer(this._rootState, action as RootAction, this._log);
276
resultingState = this._rootState;
277
}
278
279
if (isSessionAction(action)) {
280
const sessionAction = action as SessionAction;
281
const key = sessionAction.session;
282
const state = this._sessionStates.get(key);
283
if (state) {
284
const newState = sessionReducer(state, sessionAction, this._log);
285
this._sessionStates.set(key, newState);
286
287
// Detect summary changes for notification
288
if (state.summary !== newState.summary) {
289
this._dirtySummaries.add(key);
290
this._summaryNotifyScheduler.schedule();
291
}
292
293
// Track active turn for turn lifecycle
294
if (sessionAction.type === ActionType.SessionTurnStarted) {
295
this._activeTurnToSession.set(sessionAction.turnId, key);
296
this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size });
297
} else if (
298
sessionAction.type === ActionType.SessionTurnComplete ||
299
sessionAction.type === ActionType.SessionTurnCancelled ||
300
sessionAction.type === ActionType.SessionError
301
) {
302
this._activeTurnToSession.delete(sessionAction.turnId);
303
this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size });
304
}
305
306
resultingState = newState;
307
} else {
308
this._logService.warn(`[AgentHostStateManager] Action for unknown session: ${key}, type=${action.type}`);
309
}
310
}
311
312
// Emit envelope
313
const envelope: ActionEnvelope = {
314
action,
315
serverSeq: ++this._serverSeq,
316
origin,
317
};
318
319
this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`);
320
this._onDidEmitEnvelope.fire(envelope);
321
322
return resultingState;
323
}
324
325
private _flushSummaryNotifications(): void {
326
for (const session of this._dirtySummaries) {
327
const state = this._sessionStates.get(session);
328
const lastNotified = this._lastNotifiedSummaries.get(session);
329
if (!state || !lastNotified || state.summary === lastNotified) {
330
continue;
331
}
332
333
const current = state.summary;
334
const changes: Partial<SessionSummary> = {};
335
if (current.title !== lastNotified.title) { changes.title = current.title; }
336
if (current.status !== lastNotified.status) { changes.status = current.status; }
337
if (current.activity !== lastNotified.activity) { changes.activity = current.activity; }
338
if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; }
339
if (current.project !== lastNotified.project) { changes.project = current.project; }
340
if (current.model !== lastNotified.model) { changes.model = current.model; }
341
if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; }
342
if (current.diffs !== lastNotified.diffs) { changes.diffs = current.diffs; }
343
344
this._lastNotifiedSummaries.set(session, current);
345
346
if (Object.keys(changes).length > 0) {
347
this._onDidEmitNotification.fire({
348
type: NotificationType.SessionSummaryChanged,
349
session,
350
changes,
351
});
352
}
353
}
354
this._dirtySummaries.clear();
355
}
356
}
357
358