Skip to content
Open
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
6 changes: 6 additions & 0 deletions src/vs/platform/agentHost/common/sessionDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ export interface ISessionDatabase extends IDisposable {
*/
getFirstTurnEventId(): Promise<string | undefined>;

/**
* Returns whether the session database contains any persisted conversation
* turn, including host-injected local turns.
*/
hasConversationTurns(): Promise<boolean>;

/**
* Persists the JSON-serialized {@link UsageInfo} reported for a turn.
* Idempotent — last writer wins per turn.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export interface IAgentHostCatalogReconciliationOptions {
readonly canSchedule?: () => boolean;
/** Cheap pre-check so a session whose source cannot resolve never opens local storage. */
readonly isSourceAvailable?: (registered: IRegisteredSession) => boolean;
/** Mirrors retroactive provisional markers into the owner process. */
readonly onDidMarkSessionProvisional?: (session: string) => void;
}

export class AgentHostCatalogReconciliationService extends Disposable {
Expand All @@ -108,6 +110,7 @@ export class AgentHostCatalogReconciliationService extends Disposable {
private readonly _now: () => number;
private readonly _canSchedule: () => boolean;
private readonly _isSourceAvailable: (registered: IRegisteredSession) => boolean;
private readonly _onDidMarkSessionProvisional: (session: string) => void;
private readonly _scheduledPass = this._register(new MutableDisposable<IDisposable>());
private _scheduledPassKind: ScheduledPassKind | undefined;
private _payloadDirtyMark: Promise<void> | undefined;
Expand Down Expand Up @@ -149,6 +152,7 @@ export class AgentHostCatalogReconciliationService extends Disposable {
this._now = options.now ?? Date.now;
this._canSchedule = options.canSchedule ?? (() => true);
this._isSourceAvailable = options.isSourceAvailable ?? (() => true);
this._onDidMarkSessionProvisional = options.onDidMarkSessionProvisional ?? (() => { });
this._initialPayloadDirtyMarkPending = this._storageService.get<number>(VERIFICATION_VERSION_STORAGE_KEY) !== CATALOG_VERIFICATION_VERSION;
const lastVerification = this._storageService.get<number>(LAST_VERIFICATION_STORAGE_KEY);
this._lastCompatibilityVerification = typeof lastVerification === 'number' && Number.isFinite(lastVerification) && lastVerification <= this._now() ? lastVerification : 0;
Expand Down Expand Up @@ -355,6 +359,19 @@ export class AgentHostCatalogReconciliationService extends Disposable {
this._parkedSessions.delete(session);
}

private async _markEmptySourceUnresolvableAsProvisional(session: URI, database: AgentHostCatalogDatabaseReference | undefined): Promise<void> {
const sessionKey = session.toString();
try {
if (database && await database.object.hasConversationTurns()) {
return;
}
await this._catalogDatabase.setSessionProvisional(sessionKey, true);
this._onDidMarkSessionProvisional(sessionKey);
Comment on lines +368 to +369
} catch (error) {
this._logService.warn(`[AgentHostCatalogReconciliation] Failed to confirm empty source-unresolvable session ${sessionKey}`, error);
}
}

private _runBatch(
selected: readonly IRegisteredSession[],
receiptBySession: ReadonlyMap<string, IAgentHostDatabaseSessionV2Receipt>,
Expand Down Expand Up @@ -412,6 +429,7 @@ export class AgentHostCatalogReconciliationService extends Disposable {
return { session: sessionKey, status: 'retry', reason: 'providerUnavailable' };
}
if (error instanceof CatalogReconciliationSourceUnresolvableError) {
await this._markEmptySourceUnresolvableAsProvisional(session, database);
return await this._park(sessionKey, observedDirty);
}
throw error;
Expand Down Expand Up @@ -477,6 +495,7 @@ export class AgentHostCatalogReconciliationService extends Disposable {
return { session: sessionKey, status: 'retry', reason: 'providerUnavailable' };
}
if (sourceResult.status === 'sourceUnresolvable') {
await this._markEmptySourceUnresolvableAsProvisional(session, database);
return await this._park(sessionKey, observedDirty);
}
if (token.isCancellationRequested) {
Expand Down
7 changes: 7 additions & 0 deletions src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,10 @@ export class AgentService extends Disposable implements IAgentService {
...options.catalogReconciliationOptions,
canSchedule: () => this._startupSettled.isOpen() && this._isSessionCatalogEnabled(),
isSourceAvailable: registered => !!this._providerService.getProvider(registered.provider),
onDidMarkSessionProvisional: session => {
this._provisionalSessionKeys.add(session);
this._invalidateSessionList();
},
},
));
this._runWhenStartupSettled('catalog reconciliation', () => {
Expand Down Expand Up @@ -1980,6 +1984,9 @@ export class AgentService extends Disposable implements IAgentService {
}
const metadata = await this._getCatalogReconciliationMetadata(agent, registered, () => this._isChatBacking(registered.session));
if (!metadata) {
if (!this._readableProviderCatalogs.has(registered.provider)) {
return { status: 'providerUnavailable' };
}
// The provider is registered but cannot vouch for this session, so
// there is nothing authoritative to project. Reported distinctly from
// an unregistered provider so reconciliation can park it instead of
Expand Down
7 changes: 7 additions & 0 deletions src/vs/platform/agentHost/node/sessionDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,13 @@ export class SessionDatabase implements ISessionDatabase {
});
}

hasConversationTurns(): Promise<boolean> {
return this._queueOperation(async db => {
const row = await dbGet(db, `SELECT EXISTS(SELECT 1 FROM turns LIMIT 1) AS has_turns, EXISTS(SELECT 1 FROM local_turns LIMIT 1) AS has_local_turns`, []);
return !!row?.has_turns || !!row?.has_local_turns;
});
}

setTurnUsage(turnId: string, usage: string): Promise<void> {
return this._mutateTurnUsage(async db => {
// Ensure the turn exists — lazily insert since the turn record may not
Expand Down
28 changes: 27 additions & 1 deletion src/vs/platform/agentHost/test/common/sessionTestHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class TestSessionDatabase implements ISessionDatabase {
private _catalogSyncSnapshot: ISessionCatalogSyncSnapshot | undefined;
private readonly _drafts = new Map<string, Message>();
private readonly _reviewedFiles: IReviewedFileRecord[] = [];
private readonly _turns = new Set<string>();
private readonly _localTurns = new Map<string, ILocalTurnRecord>();
private readonly _turnUsages = new Map<string, string>();
private readonly _turnDelegations = new Map<string, string>();
Expand All @@ -37,9 +38,12 @@ export class TestSessionDatabase implements ISessionDatabase {
this._edits.push(edit);
}

async createTurn(): Promise<void> { }
async createTurn(turnId: string): Promise<void> {
this._turns.add(turnId);
}

async deleteTurn(turnId: string): Promise<void> {
this._turns.delete(turnId);
this._turnDelegations.delete(turnId);
this._turnWorkspaceTransitions.delete(turnId);
this._turnEventIds.delete(turnId);
Expand All @@ -52,6 +56,7 @@ export class TestSessionDatabase implements ISessionDatabase {
}

async storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise<void> {
this._turns.add(edit.turnId);
const existingIndex = this._edits.findIndex(e => e.toolCallId === edit.toolCallId && e.filePath === edit.filePath);
if (existingIndex >= 0) {
this._edits[existingIndex] = edit;
Expand Down Expand Up @@ -219,6 +224,7 @@ export class TestSessionDatabase implements ISessionDatabase {

async setTurnEventId(turnId: string, eventId: string): Promise<void> {
this.setTurnEventIdCalls.push({ turnId, eventId });
this._turns.add(turnId);
this._turnEventIds.set(turnId, eventId);
}

Expand All @@ -230,13 +236,19 @@ export class TestSessionDatabase implements ISessionDatabase {

async getFirstTurnEventId(): Promise<string | undefined> { return undefined; }

async hasConversationTurns(): Promise<boolean> {
return this._turns.size > 0 || this._localTurns.size > 0;
}

async setTurnUsage(turnId: string, usage: string): Promise<void> {
this._turns.add(turnId);
this._turnUsages.set(turnId, usage);
}

async getTurnUsages(): Promise<Map<string, string>> { return new Map(this._turnUsages); }

async setTurnDelegation(turnId: string, delegation: string): Promise<void> {
this._turns.add(turnId);
this._turnDelegations.set(turnId, delegation);
}

Expand All @@ -252,11 +264,13 @@ export class TestSessionDatabase implements ISessionDatabase {
}

async setTurnWorkspaceTransition(turnId: string, transition: string): Promise<void> {
this._turns.add(turnId);
this._turnWorkspaceTransitions.set(turnId, transition);
this._metadata.set(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, 'true');
}

async setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly<Record<string, string>>): Promise<void> {
this._turns.add(turnId);
for (const [key, value] of Object.entries(metadata)) {
this._metadata.set(key, value);
}
Expand Down Expand Up @@ -289,11 +303,13 @@ export class TestSessionDatabase implements ISessionDatabase {

async deleteAllTurns(): Promise<void> {
this.deleteAllTurnsCalls++;
this._turns.clear();
this._edits.length = 0;
this._turnDelegations.clear();
this._turnWorkspaceTransitions.clear();
this._metadata.delete(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY);
this._turnEventIds.clear();
this._localTurns.clear();
}

async insertLocalTurn(record: ILocalTurnRecord): Promise<void> {
Expand All @@ -310,6 +326,16 @@ export class TestSessionDatabase implements ISessionDatabase {
}
}
async remapTurnIds(mapping: ReadonlyMap<string, string>, eventIds?: ReadonlyMap<string, string>): Promise<void> {
for (const turnId of [...this._turns]) {
if (!mapping.has(turnId)) {
this._turns.delete(turnId);
}
}
for (const [oldId, newId] of mapping) {
if (this._turns.delete(oldId)) {
this._turns.add(newId);
}
}
for (const turnId of [...this._turnDelegations.keys()]) {
if (!mapping.has(turnId)) {
this._turnDelegations.delete(turnId);
Expand Down
Loading
Loading