Path: blob/main/src/vs/platform/agentHost/node/agentHostStateManager.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { RunOnceScheduler } from '../../../base/common/async.js';6import { Emitter, Event } from '../../../base/common/event.js';7import { Disposable } from '../../../base/common/lifecycle.js';8import { ILogService } from '../../log/common/log.js';9import { ActionType, NotificationType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, RootAction, StateAction, TerminalAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js';10import type { IStateSnapshot } from '../common/state/sessionProtocol.js';11import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js';12import { createRootState, createSessionState, SessionLifecycle, type RootState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js';13import { IPermissionsValue, platformRootSchema } from '../common/agentHostSchema.js';14import { SessionConfigKey } from '../common/sessionConfigKeys.js';1516/**17* Server-side state manager for the sessions process protocol.18*19* Maintains the authoritative state tree (root + per-session), applies actions20* through pure reducers, assigns monotonic sequence numbers, and emits21* {@link ActionEnvelope}s for subscribed clients.22*/23export class AgentHostStateManager extends Disposable {2425private _serverSeq = 0;2627private _rootState: RootState;28private readonly _sessionStates = new Map<string, SessionState>();2930/** Tracks which session URI each active turn belongs to, keyed by turnId. */31private readonly _activeTurnToSession = new Map<string, string>();3233/** Last summary sent to clients (via sessionAdded or sessionSummaryChanged). */34private readonly _lastNotifiedSummaries = new Map<string, SessionSummary>();35/** Sessions whose summary changed since the last flush. */36private readonly _dirtySummaries = new Set<string>();37private readonly _summaryNotifyScheduler = this._register(new RunOnceScheduler(() => this._flushSummaryNotifications(), 100));3839private readonly _onDidEmitEnvelope = this._register(new Emitter<ActionEnvelope>());40readonly onDidEmitEnvelope: Event<ActionEnvelope> = this._onDidEmitEnvelope.event;4142private readonly _onDidEmitNotification = this._register(new Emitter<INotification>());43readonly onDidEmitNotification: Event<INotification> = this._onDidEmitNotification.event;4445constructor(46@ILogService private readonly _logService: ILogService,47) {48super();49this._rootState = createRootState();50// Seed the host-level configuration schema + default values so that51// RootConfigChanged actions can merge into it, and clients see the52// schema immediately upon subscribing to `agenthost:/root`. See53// `platformRootSchema` for the set of platform-owned properties.54this._rootState = {55...this._rootState,56config: {57schema: platformRootSchema.toProtocol(),58values: platformRootSchema.validateOrDefault({}, {59[SessionConfigKey.Permissions]: { allow: [], deny: [] } satisfies IPermissionsValue,60}),61},62};63}64private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`);6566get hasActiveSessions(): boolean {67return this._activeTurnToSession.size > 0;68}6970// ---- State accessors ----------------------------------------------------7172get rootState(): RootState {73return this._rootState;74}7576getSessionState(session: URI): SessionState | undefined {77return this._sessionStates.get(session);78}7980get serverSeq(): number {81return this._serverSeq;82}8384getSessionUris(): string[] {85return [...this._sessionStates.keys()];86}8788/**89* Returns all session URIs whose keys start with the given prefix.90* Used to discover subagent sessions for a given parent.91*/92getSessionUrisWithPrefix(prefix: string): string[] {93const result: string[] = [];94for (const key of this._sessionStates.keys()) {95if (key.startsWith(prefix)) {96result.push(key);97}98}99return result;100}101102// ---- Snapshots ----------------------------------------------------------103104/**105* Returns a state snapshot for a given resource URI.106* The `fromSeq` in the snapshot is the current serverSeq at snapshot time;107* the client should process subsequent envelopes with serverSeq > fromSeq.108*/109getSnapshot(resource: URI): IStateSnapshot | undefined {110if (resource === ROOT_STATE_URI) {111return {112resource,113state: this._rootState,114fromSeq: this._serverSeq,115};116}117118const sessionState = this._sessionStates.get(resource);119if (!sessionState) {120return undefined;121}122123return {124resource,125state: sessionState,126fromSeq: this._serverSeq,127};128}129130// ---- Session lifecycle --------------------------------------------------131132/**133* Creates a new session in state with `lifecycle: 'creating'`.134* Returns the initial session state.135*/136createSession(summary: SessionSummary): SessionState {137const key = summary.resource;138if (this._sessionStates.has(key)) {139this._logService.warn(`[AgentHostStateManager] Session already exists: ${key}`);140return this._sessionStates.get(key)!;141}142143const state = createSessionState(summary);144this._sessionStates.set(key, state);145146this._logService.trace(`[AgentHostStateManager] Created session: ${key}`);147148this._lastNotifiedSummaries.set(key, summary);149this._onDidEmitNotification.fire({150type: NotificationType.SessionAdded,151summary,152});153154return state;155}156157/**158* Restores a session from a previous server lifetime into the state manager159* with pre-populated turns. The session is created in `ready` lifecycle160* state since it already exists on the backend.161*162* Unlike {@link createSession}, this does NOT emit a `sessionAdded`163* notification because the session is already known to clients via164* `listSessions`.165*/166restoreSession(summary: SessionSummary, turns: Turn[]): SessionState {167const key = summary.resource;168if (this._sessionStates.has(key)) {169this._logService.warn(`[AgentHostStateManager] Session already exists (restore): ${key}`);170return this._sessionStates.get(key)!;171}172173const state: SessionState = {174...createSessionState(summary),175lifecycle: SessionLifecycle.Ready,176turns,177};178this._sessionStates.set(key, state);179this._lastNotifiedSummaries.set(key, summary);180181this._logService.trace(`[AgentHostStateManager] Restored session: ${key} (${turns.length} turns)`);182183return state;184}185186/**187* Removes a session from in-memory state without emitting a notification.188* Use {@link deleteSession} when the session is being permanently deleted189* and clients need to be notified.190*/191removeSession(session: URI): void {192const state = this._sessionStates.get(session);193if (!state) {194return;195}196197// Clean up active turn tracking198if (state.activeTurn) {199this._activeTurnToSession.delete(state.activeTurn.id);200}201202this._sessionStates.delete(session);203this._lastNotifiedSummaries.delete(session);204this._dirtySummaries.delete(session);205this._logService.trace(`[AgentHostStateManager] Removed session: ${session}`);206}207208/**209* Permanently deletes a session from state and emits a210* {@link NotificationType.SessionRemoved} notification so that clients211* know the session is no longer accessible.212*/213deleteSession(session: URI): void {214this.removeSession(session);215this._onDidEmitNotification.fire({216type: NotificationType.SessionRemoved,217session,218});219}220221// ---- Session meta -------------------------------------------------------222223/**224* Replaces `state._meta` on a session by dispatching a225* {@link ActionType.SessionMetaChanged} action so the change flows226* through the action envelope (and thus to all live subscribers).227*228* The full `_meta` object is replaced (not merged) so callers stay in229* control of the convention for their own keys; use the `withSessionXxx`230* helpers in `sessionState.ts` to combine slots.231*/232setSessionMeta(session: URI, meta: SessionMeta | undefined): void {233this.dispatchServerAction({ type: ActionType.SessionMetaChanged, session, _meta: meta });234}235236// ---- Turn tracking ------------------------------------------------------237238/**239* Registers a mapping from turnId to session URI so that incoming240* provider events (which carry only session URI) can be associated241* with the correct active turn.242*/243getActiveTurnId(session: URI): string | undefined {244const state = this._sessionStates.get(session);245return state?.activeTurn?.id;246}247248// ---- Action dispatch ----------------------------------------------------249250/**251* Dispatch a server-originated action (from the agent backend).252* The action is applied to state via the reducer and emitted as an253* envelope with no origin (server-produced).254*/255dispatchServerAction(action: StateAction): void {256this._applyAndEmit(action, undefined);257}258259/**260* Dispatch a client-originated action (write-ahead from a renderer).261* The action is applied to state and emitted with the client's origin262* so the originating client can reconcile.263*/264dispatchClientAction(action: SessionAction | TerminalAction | IRootConfigChangedAction, origin: ActionOrigin): unknown {265return this._applyAndEmit(action, origin);266}267268// ---- Internal -----------------------------------------------------------269270private _applyAndEmit(action: StateAction, origin: ActionOrigin | undefined): unknown {271let resultingState: unknown = undefined;272// Apply to state273if (isRootAction(action)) {274this._rootState = rootReducer(this._rootState, action as RootAction, this._log);275resultingState = this._rootState;276}277278if (isSessionAction(action)) {279const sessionAction = action as SessionAction;280const key = sessionAction.session;281const state = this._sessionStates.get(key);282if (state) {283const newState = sessionReducer(state, sessionAction, this._log);284this._sessionStates.set(key, newState);285286// Detect summary changes for notification287if (state.summary !== newState.summary) {288this._dirtySummaries.add(key);289this._summaryNotifyScheduler.schedule();290}291292// Track active turn for turn lifecycle293if (sessionAction.type === ActionType.SessionTurnStarted) {294this._activeTurnToSession.set(sessionAction.turnId, key);295this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size });296} else if (297sessionAction.type === ActionType.SessionTurnComplete ||298sessionAction.type === ActionType.SessionTurnCancelled ||299sessionAction.type === ActionType.SessionError300) {301this._activeTurnToSession.delete(sessionAction.turnId);302this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size });303}304305resultingState = newState;306} else {307this._logService.warn(`[AgentHostStateManager] Action for unknown session: ${key}, type=${action.type}`);308}309}310311// Emit envelope312const envelope: ActionEnvelope = {313action,314serverSeq: ++this._serverSeq,315origin,316};317318this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`);319this._onDidEmitEnvelope.fire(envelope);320321return resultingState;322}323324private _flushSummaryNotifications(): void {325for (const session of this._dirtySummaries) {326const state = this._sessionStates.get(session);327const lastNotified = this._lastNotifiedSummaries.get(session);328if (!state || !lastNotified || state.summary === lastNotified) {329continue;330}331332const current = state.summary;333const changes: Partial<SessionSummary> = {};334if (current.title !== lastNotified.title) { changes.title = current.title; }335if (current.status !== lastNotified.status) { changes.status = current.status; }336if (current.activity !== lastNotified.activity) { changes.activity = current.activity; }337if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; }338if (current.project !== lastNotified.project) { changes.project = current.project; }339if (current.model !== lastNotified.model) { changes.model = current.model; }340if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; }341if (current.diffs !== lastNotified.diffs) { changes.diffs = current.diffs; }342343this._lastNotifiedSummaries.set(session, current);344345if (Object.keys(changes).length > 0) {346this._onDidEmitNotification.fire({347type: NotificationType.SessionSummaryChanged,348session,349changes,350});351}352}353this._dirtySummaries.clear();354}355}356357358