Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -472,8 +472,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 @@ -337,6 +337,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 } from '../../../base/common/event.js';
import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js';
Expand Down Expand Up @@ -96,7 +96,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 @@ -710,16 +709,50 @@ export class AgentService extends Disposable implements IAgentService {
});
}));
this._editAttributionService.setEnabled(this._stateManager.rootState.config?.values[AgentHostEditTelemetryEnabledConfigKey] !== false);
this._scheduleExternalSessionPrune();
this._runWhenStartupSettled('external session prune', () => this._pruneStaleExternalSessions());
this._register(core.disposables);
}

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 @@ -762,6 +795,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 @@ -1500,6 +1587,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 @@ -1530,6 +1618,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 @@ -1551,6 +1642,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 @@ -1583,6 +1677,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 @@ -1599,6 +1694,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 @@ -1610,6 +1708,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 @@ -1748,7 +1849,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 @@ -6695,6 +6805,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 @@ -1920,6 +1920,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