Skip to content
5 changes: 5 additions & 0 deletions src/vs/platform/agentHost/node/agentHostMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,8 +457,13 @@ async function startAgentHost(): Promise<void> {
handler => protocolHandlers.push(handler),
);
configuredWebSocketServer.settleWith(configuredWebSocketServerStart);
// Startup is complete once the last ingress has settled — successfully or
// not, since a failed WebSocket server is non-fatal. Deferred maintenance
// then runs after a client has also been served its first session listing.
void configuredWebSocketServerStart.catch(err => {
logService.error('Failed to start WebSocket server', err);
}).finally(() => {
agentService.markStartupComplete();
});

process.once('exit', () => {
Expand Down
1 change: 1 addition & 0 deletions src/vs/platform/agentHost/node/agentHostServerMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ async function main(): Promise<void> {
function reportReady(addr: string): void {
const listeningPort = Number(addr.split(':').pop());
process.stdout.write(`READY:${listeningPort}\n`);
agentService.markStartupComplete();

const urls = resolveServerUrls(options.host, listeningPort);
for (const url of urls.local) {
Expand Down
52 changes: 48 additions & 4 deletions src/vs/platform/agentHost/node/agentHostSessionTitleController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,38 @@ export class AgentHostSessionTitleController extends Disposable {
dispatch(title);
}

/**
* Generates a title for an external session whose provider surfaced it
* without one, from the user's first prompt. Such a session usually has no
* live state (it is materialized when opened), so the generated title is
* persisted and pushed onto its surfaced summary. A session that already
* carries a persisted title keeps it; a rename during generation cancels it.
*
* Unlike the other entry points this awaits generation, so the caller's
* deferred-work lane stays serialized against it.
*/
async generateExternalSessionTitle(session: ProtocolURI, userPrompt: string): Promise<void> {
if (this._isEphemeralSession(session) || await this._readPersistedTitleMetadata(session, SESSION_CUSTOM_TITLE_KEY)) {
return;
}
await this._startTitleGeneration(
session,
{ content: userPrompt, isConversation: false, gitHubReferenceSource: userPrompt },
'',
title => this._applyExternalSessionTitle(session, title),
() => true,
title => this._persistAutoTitle(session, undefined, title),
);
}

private _applyExternalSessionTitle(session: ProtocolURI, title: string): void {
if (this._stateManager.getSessionState(session)) {
this._applySeedTitle(session, undefined, title);
} else {
this._applyTitle(session, title, t => this._stateManager.updateSurfacedSessionTitle(session, t));
}
}

cancelTitleGeneration(session: ProtocolURI): void {
this._cancelTitleGeneration(session);
}
Expand Down Expand Up @@ -468,7 +500,7 @@ export class AgentHostSessionTitleController extends Disposable {
return undefined;
}
const sourceKey = independentChat ? customChatTitleSourceMetadataKey(independentChat) : SESSION_CUSTOM_TITLE_SOURCE_KEY;
const source = await this._readPersistedTitleSource(channel, sourceKey);
const source = await this._readPersistedTitleMetadata(channel, sourceKey);
if (source === AGENT_HOST_TITLE_SOURCE_USER || source === AGENT_HOST_TITLE_SOURCE_AGENT) {
this.markTitleRenamed(channel, independentChat);
return undefined;
Expand All @@ -488,10 +520,22 @@ export class AgentHostSessionTitleController extends Disposable {
currentTitleMatchesFallback: () => boolean,
persist: (title: string) => void,
): void {
void this._startTitleGeneration(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist);
}

/** Starts generation and resolves once the title has been applied and persisted. */
private _startTitleGeneration(
key: ProtocolURI,
prompt: ITitlePromptContext,
fallbackTitle: string,
apply: (title: string) => void,
currentTitleMatchesFallback: () => boolean,
persist: (title: string) => void,
): Promise<void> {
this._cancelTitleGeneration(key);
const source = new CancellationTokenSource();
this._titleGenerationCancellationSources.set(key, source);
void this._generateTitle(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist, source.token).catch(err => {
return this._generateTitle(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist, source.token).catch(err => {
if (!source.token.isCancellationRequested) {
this._logService.warn(`[AgentHostSessionTitleController] Failed to apply generated title for ${key}`, err);
}
Expand Down Expand Up @@ -810,7 +854,7 @@ export class AgentHostSessionTitleController extends Disposable {
return this._stateManager.isEphemeralSession(channel);
}

private async _readPersistedTitleSource(session: ProtocolURI, key: string): Promise<string | undefined> {
private async _readPersistedTitleMetadata(session: ProtocolURI, key: string): Promise<string | undefined> {
try {
const ref = await this._options.sessionDataService.tryOpenDatabase?.(URI.parse(session));
if (!ref) {
Expand All @@ -822,7 +866,7 @@ export class AgentHostSessionTitleController extends Disposable {
ref.dispose();
}
} catch (err) {
this._logService.warn(`[AgentHostSessionTitleController] Failed to read title source '${key}'`, err);
this._logService.warn(`[AgentHostSessionTitleController] Failed to read title metadata '${key}'`, err);
return undefined;
}
}
Expand Down
37 changes: 26 additions & 11 deletions src/vs/platform/agentHost/node/agentHostStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,20 +318,22 @@ export class AgentHostStateManager extends Disposable {
const entry = this._sessionStates.get(session);
return entry ? this._toSummary(session, entry) : undefined;
},
(session, changes) => {
this._onDidChangeSessionSummary.fire({ session, changes });
if (this._publishedSessionSummaries.has(session)) {
this._onDidEmitNotification.fire({
type: 'root/sessionSummaryChanged',
channel: ROOT_STATE_URI,
session,
changes,
});
}
},
(session, changes) => this._emitSessionSummaryChanged(session, changes),
));
}

private _emitSessionSummaryChanged(session: string, changes: SessionSummaryChangedParams['changes']): void {
this._onDidChangeSessionSummary.fire({ session, changes });
if (this._publishedSessionSummaries.has(session)) {
this._onDidEmitNotification.fire({
type: 'root/sessionSummaryChanged',
channel: ROOT_STATE_URI,
session,
changes,
});
}
}

private _emitSessionAdded(summary: SessionSummary): void {
if (readEphemeralSessionMeta(summary).isEphemeral) {
return;
Expand Down Expand Up @@ -817,6 +819,19 @@ export class AgentHostStateManager extends Disposable {
this._emitSessionAdded(summary);
}

/**
* Retitles a surfaced session (one with no live state) so clients update it
* in place. Live sessions are retitled through the reducer instead.
*/
updateSurfacedSessionTitle(session: string, title: string): void {
const announced = this._summaryNotifier.getAnnounced(session);
if (this._sessionStates.has(session) || !announced || announced.title === title) {
return;
}
this._summaryNotifier.announce(session, { ...announced, title });
this._emitSessionSummaryChanged(session, { title });
}

/** Removes a surfaced session without affecting a live session. */
retractSurfacedSession(session: string): void {
if (this._sessionStates.has(session)) {
Expand Down
133 changes: 123 additions & 10 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { open, unlink, type FileHandle } from 'fs/promises';
import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js';
import { DeferredPromise, disposableTimeout, Limiter, Promises, ResourceQueue } from '../../../base/common/async.js';
import { Barrier, DeferredPromise, disposableTimeout, Limiter, Promises, ResourceQueue } from '../../../base/common/async.js';
import { toErrorMessage } from '../../../base/common/errorMessage.js';
import { Emitter, type Event } from '../../../base/common/event.js';
import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js';
Expand Down Expand Up @@ -101,7 +101,6 @@ import { IAgentHostChangesetOperationService } from '../common/agentHostChangese
const SESSION_GC_GRACE_MS = 30_000;
const DAY_MS = 24 * 60 * 60 * 1000;
const EXTERNAL_SESSION_MAX_AGE_MS = 30 * DAY_MS;
const EXTERNAL_SESSION_PRUNE_DELAY_MS = 60_000;
const RECENT_EXTERNAL_SESSION_LIMIT = 2;
/** A catalog pass slower than this is logged at info, since it delays every session-list refresh. */
const SLOW_LIST_SESSIONS_THRESHOLD_MS = 1_000;
Expand Down Expand Up @@ -770,7 +769,7 @@ export class AgentService extends Disposable implements IAgentService {
reason: AuthRequiredReason.Required,
});
}));
this._scheduleExternalSessionPrune();
this._runWhenStartupSettled('external session prune', () => this._pruneStaleExternalSessions());
}

/**
Expand All @@ -791,12 +790,46 @@ export class AgentService extends Disposable implements IAgentService {
return this._sideEffects.onDidStartTurn;
}

private _scheduleExternalSessionPrune(): void {
this._register(disposableTimeout(() => {
void this._pruneStaleExternalSessions().catch(error => {
this._logService.warn('[AgentService] Failed to prune stale external sessions', error);
});
}, EXTERNAL_SESSION_PRUNE_DELAY_MS));
/** Opens once startup settled: the host finished starting and the first listing was served. */
private readonly _startupSettled = new Barrier();
private _hostStartupComplete = false;
private _firstListingServed = false;
/** Serializes deferred work so background maintenance never overlaps. */
private _deferredWork = Promise.resolve();

/**
* Signals that host startup finished. Deferred work runs once this and the
* first session listing have both happened, so background maintenance never
* competes with startup. Called by the process mains; the service owns no
* ambient timer of its own.
*/
markStartupComplete(): void {
this._hostStartupComplete = true;
this._openStartupSettled();
}

private _openStartupSettled(): void {
if (this._hostStartupComplete && this._firstListingServed) {
this._startupSettled.open();
}
}

/**
* Runs `work` once startup has settled, serialized behind any deferred work
* queued before it. For maintenance that is fine to run late and must not
* compete with startup — pruning stale external sessions, titling external
* sessions a provider surfaced without a title, and similar.
*/
private _runWhenStartupSettled(name: string, work: () => Promise<void>): void {
this._deferredWork = this._deferredWork
.then(() => this._startupSettled.wait())
.then(() => this._store.isDisposed ? undefined : work())
.catch(error => this._logService.warn(`[AgentService] Deferred work '${name}' failed`, error));
}

/** Test surface: settles once all deferred work queued so far has run. */
async whenDeferredWorkSettled(): Promise<void> {
await this._deferredWork;
}

private async _pruneStaleExternalSessions(): Promise<void> {
Expand Down Expand Up @@ -839,6 +872,60 @@ export class AgentService extends Disposable implements IAgentService {
this._logService.info(`[AgentService] pruned ${staleExternalSessions.length} stale external session row(s) older than ${EXTERNAL_SESSION_MAX_AGE_MS / DAY_MS} days`);
}

/** External sessions registered without a provider title, awaiting a generated one. */
private readonly _untitledExternalSessions = new Map<string, IAgentSessionMetadata>();
private _externalSessionTitlingQueued = false;

/**
* Queues external sessions whose provider surfaced them without a title.
* Titling is deferred past startup and capped at the
* {@link RECENT_EXTERNAL_SESSION_LIMIT} most recently updated candidates, so
* a large provider catalog cannot trigger a burst of model calls.
*/
private _scheduleExternalSessionTitles(sessions: readonly IAgentSessionMetadata[]): void {
for (const session of sessions) {
this._untitledExternalSessions.set(session.session.toString(), session);
}
if (this._externalSessionTitlingQueued) {
return;
}
this._externalSessionTitlingQueued = true;
this._runWhenStartupSettled('external session titles', () => {
this._externalSessionTitlingQueued = false;
return this._titleUntitledExternalSessions();
});
}

/** Titles the most recently updated queued sessions and drops the rest. */
private async _titleUntitledExternalSessions(): Promise<void> {
const candidates = [...this._untitledExternalSessions.values()]
.sort((a, b) => b.modifiedTime - a.modifiedTime)
.slice(0, RECENT_EXTERNAL_SESSION_LIMIT);
this._untitledExternalSessions.clear();
for (const candidate of candidates) {
try {
await this._generateExternalSessionTitle(candidate);
} catch (error) {
this._logService.warn(`[AgentService] Failed to title external session ${candidate.session.toString()}`, error);
}
}
}

/** Titles one external session from the first user prompt of its default chat. */
private async _generateExternalSessionTitle(metadata: IAgentSessionMetadata): Promise<void> {
const session = metadata.session;
const agent = this._findProviderForSession(session);
if (!agent) {
return;
}
const chat = URI.parse(buildDefaultChatUri(session));
const turns = await agent.chats.getMessages(chat, this._chatContext(session, chat));
const prompt = turns[0]?.message.text.trim();
if (prompt) {
await this._sideEffects.generateExternalSessionTitle(session.toString(), prompt);
}
}

// ---- provider registration ----------------------------------------------

/**
Expand Down Expand Up @@ -1581,6 +1668,7 @@ export class AgentService extends Disposable implements IAgentService {
let registeredExternal = false;
let alreadyRegistered = 0;
let registryChanged = false;
const untitledExternal: IAgentSessionMetadata[] = [];
const results = await Promise.all(chats.map(({ external, ...metadata }) => discoveryLimiter.queue(async () => {
const sessionMetadata = this._toSessionMetadata(metadata);
const session = sessionMetadata.session;
Expand Down Expand Up @@ -1611,6 +1699,9 @@ export class AgentService extends Disposable implements IAgentService {
await this._initializeExternalSessionReadState(session);
}
registeredKeys.add(session.toString());
if (external && !sessionMetadata.summary) {
untitledExternal.push(sessionMetadata);
}
if (external && !readSessionEhcliAdoptable(sessionMetadata._meta)) {
registeredExternal = true;
} else {
Expand All @@ -1632,6 +1723,9 @@ export class AgentService extends Disposable implements IAgentService {
if (registeredExternal) {
this._queueSessionListReconciliation();
}
if (untitledExternal.length > 0) {
this._scheduleExternalSessionTitles(untitledExternal);
}
this._logService.info(`[AgentService] discovery for provider ${provider.id}: ${chats.length} candidate(s) (${chats.filter(chat => chat.external).length} external), ${registered} registered, ${alreadyRegistered} already registered, ${suppressed} suppressed as subagent/chat backing, ${skippedAsStale} skipped as older than ${EXTERNAL_SESSION_MAX_AGE_MS / DAY_MS} days`);
return registered > 0;
}
Expand Down Expand Up @@ -1664,6 +1758,7 @@ export class AgentService extends Disposable implements IAgentService {
return { session: s.session, provider: provider.id, startTime: s.startTime, external, source: external ? 'discovery' : 'restore' };
})));
let registeredExternal = false;
const untitledExternal: IAgentSessionMetadata[] = [];
for (let index = 0; index < identities.length; index++) {
const identity = identities[index];
if (!identity) {
Expand All @@ -1680,6 +1775,9 @@ export class AgentService extends Disposable implements IAgentService {
await this._initializeExternalSessionReadState(identity.session);
}
existing.set(identity.session.toString(), identity.external);
if (identity.external && !metadata.summary) {
untitledExternal.push(metadata);
}
if (identity.external && !readSessionEhcliAdoptable(metadata._meta)) {
registeredExternal = true;
} else {
Expand All @@ -1691,6 +1789,9 @@ export class AgentService extends Disposable implements IAgentService {
if (registeredExternal) {
this._queueSessionListReconciliation();
}
if (untitledExternal.length > 0) {
this._scheduleExternalSessionTitles(untitledExternal);
}
}

/** Seeds external sessions as read. Avoiding this DB requires a durable registry default. */
Expand Down Expand Up @@ -1829,7 +1930,16 @@ export class AgentService extends Disposable implements IAgentService {
this._inFlightListSessions.delete(mode);
}
};
void promise.then(clear, clear);
void promise.then(
() => {
clear();
// Only a served listing ends startup: a failed one is retried, and
// deferred work must not compete with that retry.
this._firstListingServed = true;
this._openStartupSettled();
},
clear,
);
return [...await promise];
}

Expand Down Expand Up @@ -6866,6 +6976,9 @@ export class AgentService extends Disposable implements IAgentService {
}

override dispose(): void {
// Unblocks pending deferred work so its chain drains; the disposal guard
// in `_runWhenStartupSettled` keeps the work itself from running.
this._startupSettled.open();
for (const provider of this._providers.values()) {
provider.dispose();
}
Expand Down
Loading
Loading