Skip to content
4 changes: 4 additions & 0 deletions src/vs/platform/agentHost/node/agentHostMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,10 @@ async function startAgentHost(): Promise<void> {
logService.error('Failed to start WebSocket server', err);
});

// Every ingress is wired: deferred maintenance may run once a client has
// also been served its first session listing.
agentService.markStartupComplete();
Comment thread
benibenj marked this conversation as resolved.
Outdated

process.once('exit', () => {
agentService.dispose();
logService.dispose();
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
35 changes: 32 additions & 3 deletions src/vs/platform/agentHost/node/agentHostSessionTitleController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,35 @@ 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.
*/
async generateExternalSessionTitle(session: ProtocolURI, userPrompt: string): Promise<void> {
if (this._isEphemeralSession(session) || await this._readPersistedTitleMetadata(session, SESSION_CUSTOM_TITLE_KEY)) {
return;
}
this._generateTitleSoon(
session,
{ content: userPrompt, isConversation: false, gitHubReferenceSource: userPrompt },
'',
title => this._applyExternalSessionTitle(session, title),
() => true,
title => this._persistAutoTitle(session, undefined, title),
);
Comment thread
benibenj marked this conversation as resolved.
Outdated
}

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 +497,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 Down Expand Up @@ -810,7 +839,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 +851,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 @@ -316,20 +316,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 @@ -815,6 +817,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
124 changes: 115 additions & 9 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 @@ -767,7 +766,7 @@ export class AgentService extends Disposable implements IAgentService {
reason: AuthRequiredReason.Required,
});
}));
this._scheduleExternalSessionPrune();
this._runWhenStartupSettled('external session prune', () => this._pruneStaleExternalSessions());
}

/**
Expand All @@ -788,12 +787,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 @@ -836,6 +869,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 @@ -1585,6 +1672,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 @@ -1614,6 +1702,9 @@ export class AgentService extends Disposable implements IAgentService {
await this._initializeExternalSessionReadState(session);
}
existing.set(session.toString(), external);
if (external && !sessionMetadata.summary) {
untitledExternal.push(sessionMetadata);
}
if (external && !readSessionEhcliAdoptable(sessionMetadata._meta)) {
registeredExternal = true;
} else {
Expand All @@ -1635,6 +1726,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 @@ -1663,6 +1757,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 @@ -1679,6 +1774,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 @@ -1690,6 +1788,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 @@ -1801,6 +1902,8 @@ export class AgentService extends Disposable implements IAgentService {
if (this._inFlightListSessions.get(mode) === entry) {
this._inFlightListSessions.delete(mode);
}
this._firstListingServed = true;
this._openStartupSettled();
};
void promise.then(clear, clear);
Comment thread
benibenj marked this conversation as resolved.
Outdated
return [...await promise];
Expand Down Expand Up @@ -6769,6 +6872,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
5 changes: 5 additions & 0 deletions src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1914,6 +1914,11 @@ export class AgentSideEffects extends Disposable {
this._titleController.markTitleAuto(channel, chatChannel, title);
}

/** Generates a title for an external session the provider surfaced without one. */
generateExternalSessionTitle(session: ProtocolURI, userPrompt: string): Promise<void> {
return this._titleController.generateExternalSessionTitle(session, userPrompt);
}

markTitleRenamed(channel: ProtocolURI, chatChannel?: ProtocolURI): void {
this._titleController.markTitleRenamed(channel, chatChannel);
}
Expand Down
Loading
Loading