diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index f4b28c2034c805..eeb35a318b6122 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1091,6 +1091,16 @@ export interface IAgentChatAdoptionResult { readonly eligible: boolean; /** Whether the chat already has Agent Host metadata, i.e. it is ours regardless of adoption. */ readonly native?: boolean; + /** Host-owned list-visible values recovered from the predecessor format. */ + readonly listVisible?: ({ + readonly title: string; + readonly titleSource: 'user' | 'agent' | 'auto'; + } | { + readonly title?: undefined; + readonly titleSource?: undefined; + }) & { + readonly isRead?: boolean; + }; /** Set when the adopted chat ran in a worktree that no longer exists and can be recreated. */ readonly worktree?: IAgentAdoptedWorktree; /** Diagnostic reason behind {@link adopted}. */ diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index a2737973c1bcda..4c8dab16007ca3 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -105,6 +105,40 @@ export interface ILocalTurnRecord { payload: string; } +interface ISessionCatalogSyncIdentity { + readonly sessionGeneration: string; + readonly sourceRevision: number; + readonly projectionVersion: number; +} + +/** Durable canonical catalog projection awaiting central acknowledgement. */ +export interface ISessionCatalogSyncPendingSnapshot extends ISessionCatalogSyncIdentity { + readonly payload: string; + readonly payloadHash: string; + readonly acknowledgedHash?: string; + readonly state: 'pending'; +} + +/** Compact receipt retained after the pending payload has been acknowledged. */ +export interface ISessionCatalogSyncAcknowledgedSnapshot extends ISessionCatalogSyncIdentity { + readonly payload: undefined; + readonly payloadHash: string; + readonly acknowledgedHash: string; + readonly state: 'acknowledged'; +} + +export type ISessionCatalogSyncSnapshot = ISessionCatalogSyncPendingSnapshot | ISessionCatalogSyncAcknowledgedSnapshot; + +/** Identity fields required to acknowledge exactly one catalog synchronization snapshot. */ +export interface ISessionCatalogSyncAcknowledgement { + readonly sessionGeneration: string; + readonly sourceRevision: number; + readonly projectionVersion: number; + readonly payloadHash: string; +} + +/** Outcome of atomically storing metadata with a catalog synchronization snapshot. */ +export type SessionCatalogSyncWriteResult = 'applied' | 'replayed'; /** * A disposable handle to a per-session SQLite database backed by @@ -308,6 +342,26 @@ export interface ISessionDatabase extends IDisposable { */ setMetadataValuesIfAbsent(key: string, values: Readonly>, copies?: Readonly>): Promise; + /** + * Atomically stores metadata and advances the durable catalog relay snapshot. + */ + setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise; + + /** + * Atomically transitions to a new session generation when the stored generation matches. + */ + transitionMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, expectedSessionGeneration: string, snapshot: ISessionCatalogSyncPendingSnapshot): Promise; + + /** + * Returns the durable catalog relay snapshot, if one has been stored. + */ + getCatalogSyncSnapshot(): Promise; + + /** + * Acknowledges the snapshot only when every supplied identity field still matches. + */ + acknowledgeCatalogSyncSnapshot(acknowledgement: ISessionCatalogSyncAcknowledgement): Promise; + /** * Store or clear the draft for a chat in this session. */ diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index c6ff31388aba0a..81102242c513ac 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -166,6 +166,8 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt const copilotApiService = instantiationService.invokeFunction(accessor => accessor.get(ICopilotApiService)); services.set(IAgentHostSessionTitleController, infrastructure.add(instantiationService.createInstance(AgentHostSessionTitleController, foundation.stateManager, { sessionDataService, + queueCatalogSync: (session, metadataOverrides) => foundation.callbackAdapter.value.queueCatalogSync(session, metadataOverrides), + persistSurfacedSessionTitle: (session, title) => foundation.callbackAdapter.value.persistSurfacedSessionTitle(session, title), getGitHubCopilotToken: () => { const resource = foundation.gitHubEndpointService.getCopilotResource(); return foundation.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); diff --git a/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts new file mode 100644 index 00000000000000..0baecab7d20866 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AgentSession, type IAgentSessionMetadata } from '../common/agent.js'; +import { SessionStatus, withSessionExternal, withSessionStatusFlag } from '../common/state/sessionState.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload, reviveAgentHostCatalogData, type AgentHostCatalogRevivedData } from './agentHostCatalogProjection.js'; +import type { IAgentHostDatabase } from './agentHostDatabase.js'; +import type { IRegisteredSession } from './agentSessionRegistry.js'; + +export type AgentHostCatalogListResult = + /** The central row is authoritative for this session's listing. */ + | { readonly eligible: true; readonly metadata: IAgentSessionMetadata; readonly data: AgentHostCatalogRevivedData } + /** + * The central row marks the session as a chat backing. It is deliberately + * hidden and must never fall back into the top-level list. + */ + | { readonly eligible: false; readonly chatBacking: true } + /** The central row is missing, stale or unusable; the caller falls back and schedules a repair. */ + | { readonly eligible: false; readonly chatBacking: false; readonly detail: string; readonly error?: Error }; + +/** + * Eligibility boundary between the `sessions_v2` catalog and the session list: + * it checks that a stored row still describes the registered session, then + * hands the payload's own decoded data to the caller without re-parsing it. + */ +export class AgentHostCatalogListReader { + + constructor(private readonly _catalogDatabase: IAgentHostDatabase) { } + + async read(registered: IRegisteredSession): Promise { + const session = registered.session.toString(); + try { + const catalog = await this._catalogDatabase.getSessionV2(session); + if (!catalog) { + return ineligible('no central row'); + } + if (catalog.session !== session) { + return ineligible(`central row identity ${catalog.session} does not match`); + } + if (catalog.isChatBacking) { + return { eligible: false, chatBacking: true }; + } + if (AgentSession.provider(registered.session) !== registered.provider || catalog.provider !== registered.provider) { + return ineligible(`central row provider ${catalog.provider} does not match ${registered.provider}`); + } + if (catalog.payloadVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION) { + return ineligible(`central row payload version ${catalog.payloadVersion} is outdated`); + } + const decoded = decodeAgentHostCatalogPayload(catalog.payload); + if (!decoded.ok) { + return ineligible(`central payload is ${decoded.reason}: ${decoded.error}`); + } + // A payload can only become chat-backing through a write that also + // updates the row marker, but an inconsistent row must still hide + // the session rather than surface a backing as a top-level entry. + if (decoded.value.data.isChatBacking) { + return { eligible: false, chatBacking: true }; + } + const data = reviveAgentHostCatalogData(decoded.value.data); + return { eligible: true, metadata: this._toSessionMetadata(registered, data), data }; + } catch (error) { + return { + eligible: false, + chatBacking: false, + detail: 'central row read failed', + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + private _toSessionMetadata(registered: IRegisteredSession, data: AgentHostCatalogRevivedData): IAgentSessionMetadata { + let status = withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsRead, data.isRead); + status = withSessionStatusFlag(status, SessionStatus.IsArchived, data.isArchived); + const meta = withSessionExternal(data._meta, registered.external); + return { + session: registered.session, + startTime: registered.startTime, + // The registry owns durable recency: a live advance can outrun the + // payload's own timestamp until the next reconciliation writes it back. + modifiedTime: Math.max(data.modifiedTime, registered.modifiedTime), + summary: data.summary, + status, + project: data.project, + workingDirectories: [...data.workingDirectories], + changes: data.changes, + ...(meta !== undefined ? { _meta: meta } : {}), + }; + } +} + +function ineligible(detail: string): AgentHostCatalogListResult { + return { eligible: false, chatBacking: false, detail }; +} diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts new file mode 100644 index 00000000000000..29b983984cc896 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -0,0 +1,465 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from 'crypto'; +import { IJSONSchema } from '../../../base/common/jsonSchema.js'; +import { stableStringify } from '../../../base/common/objects.js'; +import { URI } from '../../../base/common/uri.js'; +import { IValidator, ValidationError, ValidatorBase, ValidatorType, vArray, vBoolean, vEnum, vObj, vOptionalProp } from '../../../base/common/validation.js'; +import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY, isAgentDevContainerWorktreeHandle } from '../common/meta/agentDevContainerWorktreeMeta.js'; +import { SESSION_META_ARTIFACTS_KEY } from '../common/sessionArtifacts.js'; +import { SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY } from '../common/state/sessionState.js'; + +export const AGENT_HOST_CATALOG_PAYLOAD_VERSION = 1; +export const AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT = 10; +export const AGENT_HOST_CATALOG_ARTIFACT_LIMIT = 100; +export const AGENT_HOST_CATALOG_CHILD_LIMIT = 1000; +export const AGENT_HOST_CATALOG_PAYLOAD_BYTE_LIMIT = 4 * 1024 * 1024; +export const AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT = 1024; +export const AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT = 4096; + +const MAX_JSON_DEPTH = 20; +const MAX_JSON_ENTRIES = 2000; + +class RefinedValidator extends ValidatorBase { + constructor( + private readonly validator: IValidator, + private readonly refine: (value: T, original: unknown) => T | ValidationError, + ) { + super(); + } + + validate(content: unknown): { content: T; error: undefined } | { content: undefined; error: ValidationError } { + const result = this.validator.validate(content); + if (result.error) { + return result; + } + const refined = this.refine(result.content, content); + return isRefinementError(refined) + ? { content: undefined, error: refined } + : { content: refined, error: undefined }; + } + + getJSONSchema(): IJSONSchema { + return this.validator.getJSONSchema(); + } +} + +class StringValidator extends ValidatorBase { + constructor( + private readonly maximumLength: number, + private readonly uri: boolean, + ) { + super(); + } + + validate(content: unknown): { content: string; error: undefined } | { content: undefined; error: ValidationError } { + if (typeof content !== 'string' || content.length === 0) { + return { content: undefined, error: { message: 'Expected a non-empty string.' } }; + } + if (content.length > this.maximumLength) { + return { content: undefined, error: { message: `String exceeds ${this.maximumLength} characters.` } }; + } + if (this.uri) { + try { + if (!URI.parse(content, true).scheme) { + return { content: undefined, error: { message: 'Expected a URI with a scheme.' } }; + } + } catch (error) { + return { content: undefined, error: { message: error instanceof Error ? error.message : 'Expected a valid URI.' } }; + } + } + return { content, error: undefined }; + } + + getJSONSchema(): IJSONSchema { + return { type: 'string', minLength: 1, maxLength: this.maximumLength }; + } +} + +class SafeIntegerValidator extends ValidatorBase { + validate(content: unknown): { content: number; error: undefined } | { content: undefined; error: ValidationError } { + return typeof content === 'number' && Number.isSafeInteger(content) && content >= 0 + ? { content, error: undefined } + : { content: undefined, error: { message: 'Expected a non-negative safe integer.' } }; + } + + getJSONSchema(): IJSONSchema { + return { type: 'integer', minimum: 0 }; + } +} + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +/** Forward-compatible JSON accepted for payload fields whose shape the catalog does not own. */ +export type AgentHostCatalogJsonValue = JsonValue; + +class JsonValueValidator extends ValidatorBase { + validate(content: unknown): { content: JsonValue; error: undefined } | { content: undefined; error: ValidationError } { + let entries = 0; + const ancestors = new Set(); + const visit = (value: unknown, depth: number): { value: JsonValue; error?: undefined } | { value?: undefined; error: ValidationError } => { + if (depth > MAX_JSON_DEPTH) { + return { error: { message: `JSON nesting exceeds ${MAX_JSON_DEPTH} levels.` } }; + } + if (value === null || typeof value === 'boolean') { + return { value }; + } + if (typeof value === 'string') { + return value.length <= AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT + ? { value } + : { error: { message: `String exceeds ${AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT} characters.` } }; + } + if (typeof value === 'number') { + return Number.isFinite(value) + ? { value } + : { error: { message: 'Expected a finite JSON number.' } }; + } + if (typeof value !== 'object' || ancestors.has(value)) { + return { error: { message: 'Expected a non-circular JSON value.' } }; + } + ancestors.add(value); + if (Array.isArray(value)) { + entries += value.length; + if (entries > MAX_JSON_ENTRIES) { + return { error: { message: `JSON value exceeds ${MAX_JSON_ENTRIES} entries.` } }; + } + const result: JsonValue[] = []; + for (const entry of value) { + const parsed = visit(entry, depth + 1); + if (parsed.error) { + return parsed; + } + result.push(parsed.value); + } + ancestors.delete(value); + return { value: result }; + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + return { error: { message: 'Expected a plain JSON object.' } }; + } + const keys = Object.keys(value).sort(); + entries += keys.length; + if (entries > MAX_JSON_ENTRIES) { + return { error: { message: `JSON value exceeds ${MAX_JSON_ENTRIES} entries.` } }; + } + const result: { [key: string]: JsonValue } = {}; + for (const key of keys) { + if (key.length > AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT) { + return { error: { message: `JSON key exceeds ${AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT} characters.` } }; + } + const parsed = visit((value as Record)[key], depth + 1); + if (parsed.error) { + return parsed; + } + result[key] = parsed.value; + } + ancestors.delete(value); + return { value: result }; + }; + const result = visit(content, 0); + return result.error + ? { content: undefined, error: result.error } + : { content: result.value, error: undefined }; + } + + getJSONSchema(): IJSONSchema { + return {}; + } +} + +const boundedString = (maximumLength = AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT) => new StringValidator(maximumLength, false); +const uriString = () => new StringValidator(AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT, true); +const safeInteger = () => new SafeIntegerValidator(); +const jsonValue = () => new JsonValueValidator(); + +function boundedArray(validator: IValidator, maximumLength: number): ValidatorBase { + return new RefinedValidator(vArray(validator), value => value.length <= maximumLength + ? value + : { message: `Expected at most ${maximumLength} entries.` }); +} + +function plainObject(validator: IValidator): ValidatorBase { + return new RefinedValidator(validator, (value, original) => + typeof original === 'object' && original !== null && !Array.isArray(original) && Object.getPrototypeOf(original) === Object.prototype + ? value + : { message: 'Expected a plain object.' }); +} + +export const agentHostCatalogChangesValidator = plainObject(vObj({ + additions: vOptionalProp(safeInteger()), + deletions: vOptionalProp(safeInteger()), + files: vOptionalProp(safeInteger()), +})); + +const projectValidator = plainObject(vObj({ + uri: uriString(), + displayName: boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT), +})); + +const multiRootValidator = plainObject(vObj({ + workspaceFile: uriString(), +})); + +const folderPickerValidator = new RefinedValidator(plainObject(vObj({ + hidden: vBoolean(), + primary: vOptionalProp(uriString()), +})), value => value.primary !== undefined && !value.hidden + ? { message: 'A pinned primary directory requires hidden to be true.' } + : value); + +const githubReferencesValidator = boundedArray(uriString(), AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT); +const githubValidator = plainObject(vObj({ + owner: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), + repo: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), + pullRequestUrls: vOptionalProp(githubReferencesValidator), + initialPullRequestUrls: vOptionalProp(githubReferencesValidator), + associatedPullRequestUrls: vOptionalProp(githubReferencesValidator), + issueUrls: vOptionalProp(githubReferencesValidator), + pullRequestState: vOptionalProp(vEnum('open', 'closed', 'merged')), + pullRequestStateUrl: vOptionalProp(uriString()), + pullRequestBranchName: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), +})); + +const gitValidator = plainObject(vObj({ + hasGitHubRemote: vOptionalProp(vBoolean()), + branchName: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), + isDetachedHead: vOptionalProp(vBoolean()), + baseBranchName: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), + upstreamBranchName: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), + incomingChanges: vOptionalProp(safeInteger()), + outgoingChanges: vOptionalProp(safeInteger()), + uncommittedChanges: vOptionalProp(safeInteger()), + hasBaseBranchChanges: vOptionalProp(vBoolean()), + githubOwner: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), + githubHeadOwner: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), + githubRepo: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), +})); + +/** Exposed so persisted git metadata is parsed by the payload authority instead of a private copy. */ +export const agentHostCatalogGitValidator: IValidator> = gitValidator; + +const sourceControlValidator = new RefinedValidator(plainObject(vObj({ + merge: vOptionalProp(plainObject(vObj({ commit: boundedString() }))), + latestOutcome: vOptionalProp(vEnum('merge', 'pullRequest')), +})), value => value.latestOutcome === 'merge' && value.merge === undefined + ? { message: 'A merge outcome requires a commit.' } + : value); + +const artifactValidator = plainObject(vObj({ + id: boundedString(), + type: vEnum('pullRequest', 'issue', 'commit', 'website', 'file', 'resource'), + label: boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT), + isArtifact: vOptionalProp(vBoolean()), + link: vOptionalProp(boundedString()), + uri: vOptionalProp(boundedString()), + commitHash: vOptionalProp(boundedString()), + isGitHub: vOptionalProp(vBoolean()), +})); + +const artifactsValidator = new RefinedValidator( + vArray(artifactValidator), + value => { + const retained = value.slice(-AGENT_HOST_CATALOG_ARTIFACT_LIMIT); + return hasUniqueValues(retained, artifact => artifact.id) ? retained : { message: 'Artifact ids must be unique.' }; + }, +); + +const creationReferenceValidator = plainObject(vObj({ + session: uriString(), + chat: vOptionalProp(uriString()), + turnId: vOptionalProp(boundedString()), +})); + +const devContainerWorktreeValidator = new RefinedValidator(plainObject(vObj({ + version: safeInteger(), + handle: boundedString(), +})), value => value.version === 1 && isAgentDevContainerWorktreeHandle(value.handle) + ? value + : { message: 'Expected valid Dev Container worktree metadata.' }); + +/** + * The session's `_meta` bag, validated slot by slot under the same well-known + * keys `sessionState.ts` uses, so readers such as `readSessionGitState` accept + * it as-is. Unknown keys are stripped. + */ +const metadataValidator = plainObject(vObj({ + [SESSION_META_MULTI_ROOT_KEY]: vOptionalProp(multiRootValidator), + [SESSION_META_FOLDER_PICKER_KEY]: vOptionalProp(folderPickerValidator), + [SESSION_META_GITHUB_KEY]: vOptionalProp(githubValidator), + [SESSION_META_GIT_KEY]: vOptionalProp(gitValidator), + [SESSION_META_SOURCE_CONTROL_KEY]: vOptionalProp(sourceControlValidator), + [SESSION_META_ARTIFACTS_KEY]: vOptionalProp(artifactsValidator), + [SESSION_META_CREATED_BY_SESSION_KEY]: vOptionalProp(creationReferenceValidator), + [SESSION_META_WORKSPACELESS_KEY]: vOptionalProp(vBoolean()), + [SESSION_META_EHCLI_ADOPTABLE_KEY]: vOptionalProp(vBoolean()), + [SESSION_META_EHCLI_ADOPTED_KEY]: vOptionalProp(vBoolean()), + [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: vOptionalProp(devContainerWorktreeValidator), +})); + +const chatValidator = plainObject(vObj({ + uri: uriString(), + order: safeInteger(), + kind: vEnum('default', 'peer'), + summary: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), + titleSource: vOptionalProp(vEnum('user', 'agent', 'auto')), + origin: vOptionalProp(jsonValue()), + inheritedTurnId: vOptionalProp(boundedString(AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT)), +})); + +const chatsValidator = new RefinedValidator( + boundedArray(chatValidator, AGENT_HOST_CATALOG_CHILD_LIMIT), + value => { + const sorted = value.slice().sort((a, b) => a.order - b.order); + if (!hasUniqueValues(sorted, chat => chat.uri)) { + return { message: 'Chat URIs must be unique.' }; + } + if (sorted.some((chat, index) => chat.order !== index)) { + return { message: 'Chat orders must form a contiguous zero-based sequence.' }; + } + return sorted; + }, +); + +const workingDirectoriesValidator = new RefinedValidator( + boundedArray(uriString(), AGENT_HOST_CATALOG_CHILD_LIMIT), + value => hasUniqueValues(value, directory => directory) ? value : { message: 'Working directories must be unique.' }, +); + +export const agentHostCatalogDataValidator = plainObject(vObj({ + modifiedTime: safeInteger(), + summary: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), + titleSource: vOptionalProp(vEnum('user', 'agent', 'auto')), + isRead: vBoolean(), + isArchived: vBoolean(), + project: vOptionalProp(projectValidator), + isChatBacking: vOptionalProp(vBoolean()), + workingDirectories: workingDirectoriesValidator, + changes: vOptionalProp(agentHostCatalogChangesValidator), + _meta: vOptionalProp(metadataValidator), + chats: chatsValidator, +})); + +const payloadValidator = plainObject(vObj({ + payloadVersion: safeInteger(), + data: agentHostCatalogDataValidator, +})); + +export type AgentHostCatalogData = ValidatorType; +export type AgentHostCatalogChat = AgentHostCatalogData['chats'][number]; +export type AgentHostCatalogMetadata = NonNullable; + +export type AgentHostCatalogRevivedData = Omit & { + readonly project?: Omit, 'uri'> & { readonly uri: URI }; + readonly workingDirectories: readonly URI[]; + readonly chats: ReadonlyArray & { readonly uri: URI }>; +}; + +export interface IAgentHostCatalogDecodedPayload { + readonly data: AgentHostCatalogData; + /** Canonical serialization of {@link data}; equal for every input that validates to the same data. */ + readonly payload: string; +} + +export interface IAgentHostCatalogEncodedPayload extends IAgentHostCatalogDecodedPayload { + readonly payloadHash: string; +} + +export type AgentHostCatalogPayloadResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly reason: 'invalid' | 'outdated'; readonly error: string }; + +/** + * Validates `data` and returns its canonical payload plus content hash. The result carries no + * database identity: callers own session, generation and revision. + */ +export function encodeAgentHostCatalogPayload(data: AgentHostCatalogData): AgentHostCatalogPayloadResult { + const normalized = agentHostCatalogDataValidator.validate(data); + if (normalized.error) { + return invalidPayload(normalized.error.message); + } + const payload = stableStringify({ + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + data: normalized.content, + }); + if (Buffer.byteLength(payload, 'utf8') > AGENT_HOST_CATALOG_PAYLOAD_BYTE_LIMIT) { + return invalidPayload(`Payload exceeds ${AGENT_HOST_CATALOG_PAYLOAD_BYTE_LIMIT} bytes.`); + } + return { + ok: true, + value: { + data: normalized.content, + payload, + payloadHash: hashAgentHostCatalogPayload(payload), + }, + }; +} + +/** Validates a stored payload and returns its canonical form without hashing it. */ +export function decodeAgentHostCatalogPayload(payload: string): AgentHostCatalogPayloadResult { + if (Buffer.byteLength(payload, 'utf8') > AGENT_HOST_CATALOG_PAYLOAD_BYTE_LIMIT) { + return invalidPayload(`Payload exceeds ${AGENT_HOST_CATALOG_PAYLOAD_BYTE_LIMIT} bytes.`); + } + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch (error) { + return invalidPayload(error instanceof Error ? error.message : 'Malformed JSON.'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return invalidPayload('Expected a payload object.'); + } + const payloadVersion = (parsed as Record)['payloadVersion']; + if (typeof payloadVersion !== 'number' || !Number.isSafeInteger(payloadVersion) || payloadVersion < 0) { + return invalidPayload('Expected a non-negative safe integer payloadVersion.'); + } + if (payloadVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION) { + return { ok: false, reason: 'outdated', error: `Expected payload version ${AGENT_HOST_CATALOG_PAYLOAD_VERSION}, but got ${payloadVersion}.` }; + } + const result = payloadValidator.validate(parsed); + if (result.error) { + return invalidPayload(result.error.message); + } + return { + ok: true, + value: { + data: result.content.data, + payload: stableStringify(result.content), + }, + }; +} + +export function reviveAgentHostCatalogData(data: AgentHostCatalogData): AgentHostCatalogRevivedData { + return { + ...data, + project: data.project ? { ...data.project, uri: URI.parse(data.project.uri, true) } : undefined, + workingDirectories: data.workingDirectories.map(directory => URI.parse(directory, true)), + chats: data.chats.map(chat => ({ ...chat, uri: URI.parse(chat.uri, true) })), + }; +} + +export function hashAgentHostCatalogPayload(payload: string): string { + return createHash('sha256').update(payload, 'utf8').digest('hex'); +} + +function invalidPayload(error: string): AgentHostCatalogPayloadResult { + return { ok: false, reason: 'invalid', error }; +} + +function hasUniqueValues(values: readonly T[], getKey: (value: T) => string): boolean { + const keys = new Set(); + for (const value of values) { + const key = getKey(value); + if (keys.has(key)) { + return false; + } + keys.add(key); + } + return true; +} + +function isRefinementError(value: T | ValidationError): value is ValidationError { + return typeof value === 'object' && value !== null && !Array.isArray(value) && 'message' in value; +} diff --git a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts new file mode 100644 index 00000000000000..7ed31203d845af --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts @@ -0,0 +1,638 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout, Limiter } from '../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot } from '../common/sessionDataService.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, hashAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; +import { AgentHostCatalogDatabaseReference, AgentHostCatalogDeletionFencedError, AgentHostCatalogSyncResult, AgentHostCatalogSyncService, catalogLegacyMetadataMatches, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.js'; +import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; +import type { IRegisteredSession } from './agentSessionRegistry.js'; +import type { IAgentHostStorageService } from './agentHostStorageService.js'; + +const DEFAULT_BATCH_SIZE = 50; +const DEFAULT_CONCURRENCY = 4; +const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; +const DEFAULT_FULL_VERIFICATION_INTERVAL_MS = 60 * 60 * 1000; +const DEFAULT_BACKGROUND_DELAY_MS = 1000; +const RECONCILIATION_CURSOR_STORAGE_KEY = 'agentHost.catalogReconciliation.cursor'; +type AgentHostCatalogSyncPendingReason = Extract['reason']; +type ScheduledPassKind = 'background' | 'periodic'; + +function compareSessionKeys(first: string, second: string): number { + return first < second ? -1 : first > second ? 1 : 0; +} + +function receiptsEqual(first: IAgentHostDatabaseSessionV2Receipt | undefined, second: IAgentHostDatabaseSessionV2Receipt | undefined): boolean { + return first?.sessionGeneration === second?.sessionGeneration + && first?.sourceRevision === second?.sourceRevision + && first?.payloadVersion === second?.payloadVersion + && first?.payloadHash === second?.payloadHash + && first?.payloadDirty === second?.payloadDirty; +} + +class CatalogReconciliationSupersededError extends Error { } +class CatalogReconciliationProviderUnavailableError extends Error { } + +export type AgentHostCatalogReconciliationOutcome = + | { readonly session: string; readonly status: 'skipped'; readonly reason: 'synchronized' } + | { readonly session: string; readonly status: 'succeeded'; readonly reason: 'pendingReplayed' | 'synchronized'; readonly sourceRevision: number } + | { readonly session: string; readonly status: 'pending'; readonly reason: AgentHostCatalogSyncPendingReason; readonly sourceRevision: number } + | { readonly session: string; readonly status: 'retry'; readonly reason: 'providerUnavailable' | 'missingCatalog' | 'staleIncarnation' | 'superseded' | 'tombstoned' | 'cancelled' } + | { readonly session: string; readonly status: 'failed'; readonly reason: 'malformedPayload' | 'payloadMismatch' | 'centralApplyFailed' | 'acknowledgementSuperseded' | 'unexpected'; readonly error?: string }; + +export interface IAgentHostCatalogReconciliationReport { + readonly outcomes: readonly AgentHostCatalogReconciliationOutcome[]; + readonly cursor: string | undefined; +} + +export type AgentHostCatalogReconciliationSourceResult = + | { readonly status: 'available'; readonly request: IAgentHostCatalogSyncRequest } + | { readonly status: 'providerUnavailable' }; + +export interface IAgentHostCatalogReconciliationOptions { + readonly batchSize?: number; + readonly concurrency?: number; + readonly cursorStorageKey?: string; + readonly intervalMs?: number; + readonly fullVerificationIntervalMs?: number; + readonly backgroundDelayMs?: number; + readonly schedule?: (callback: () => void, delay: number) => IDisposable; + readonly now?: () => number; +} + +export class AgentHostCatalogReconciliationService extends Disposable { + + private readonly _cancellation = this._register(new CancellationTokenSource()); + private readonly _batchSize: number; + private readonly _concurrency: number; + private readonly _cursorStorageKey: string; + private readonly _intervalMs: number; + private readonly _fullVerificationIntervalMs: number; + private readonly _backgroundDelayMs: number; + private readonly _schedule: (callback: () => void, delay: number) => IDisposable; + private readonly _now: () => number; + private readonly _scheduledPass = this._register(new MutableDisposable()); + private _scheduledPassKind: ScheduledPassKind | undefined; + private _payloadDirtyMark: Promise | undefined; + private _initialPayloadDirtyMarkPending = true; + private _lastFullVerification = 0; + private _running: Promise | undefined; + private _rerunRequested = false; + private _periodic = false; + + constructor( + private readonly _catalogDatabase: IAgentHostDatabase, + private readonly _catalogSyncService: AgentHostCatalogSyncService, + private readonly _storageService: IAgentHostStorageService, + private readonly _listSessions: () => Promise, + private readonly _resolveSource: (registered: IRegisteredSession, database: AgentHostCatalogDatabaseReference | undefined) => Promise, + private readonly _logService: ILogService, + options: IAgentHostCatalogReconciliationOptions = {}, + ) { + super(); + this._batchSize = this._positiveInteger(options.batchSize, DEFAULT_BATCH_SIZE, 'batchSize'); + this._concurrency = this._positiveInteger(options.concurrency, DEFAULT_CONCURRENCY, 'concurrency'); + this._cursorStorageKey = options.cursorStorageKey ?? RECONCILIATION_CURSOR_STORAGE_KEY; + this._intervalMs = this._positiveInteger(options.intervalMs, DEFAULT_INTERVAL_MS, 'intervalMs'); + this._fullVerificationIntervalMs = this._positiveInteger(options.fullVerificationIntervalMs, DEFAULT_FULL_VERIFICATION_INTERVAL_MS, 'fullVerificationIntervalMs'); + this._backgroundDelayMs = this._nonNegativeInteger(options.backgroundDelayMs, DEFAULT_BACKGROUND_DELAY_MS, 'backgroundDelayMs'); + this._schedule = options.schedule ?? ((callback, delay) => disposableTimeout(callback, delay)); + this._now = options.now ?? Date.now; + } + + schedule(): void { + if (this._cancellation.token.isCancellationRequested) { + return; + } + this._periodic = true; + if (this._running) { + void this.runPass(); + return; + } + if (this._scheduledPassKind === 'background') { + return; + } + this._schedulePass('background', this._backgroundDelayMs); + } + + start(): void { + if (this._cancellation.token.isCancellationRequested) { + return; + } + this._periodic = true; + this._scheduledPass.clear(); + this._scheduledPassKind = undefined; + const pass = this.runPass(); + void pass.then( + report => this._logOutcomes(report.outcomes), + error => this._logService.error('[AgentHostCatalogReconciliation] Background pass failed', error), + ); + } + + runPass(): Promise { + if (this._cancellation.token.isCancellationRequested) { + return Promise.resolve({ outcomes: [], cursor: this._readCursor() }); + } + if (this._running) { + this._rerunRequested = true; + return this._running; + } + return this._startRun(() => this._runPassLoop(() => this._runSinglePass(this._cancellation.token))); + } + + async runFullPass(): Promise { + while (this._running) { + await this._running; + } + await this._prepareFullVerification(); + if (this._running) { + return this.runFullPass(); + } + if (this._cancellation.token.isCancellationRequested) { + return { outcomes: [], cursor: this._readCursor() }; + } + return this._startRun(() => this._runPassLoop(() => this._runFullPass(this._cancellation.token))); + } + + async whenIdle(): Promise { + if (this._scheduledPassKind === 'background') { + this._scheduledPass.clear(); + this._scheduledPassKind = undefined; + await this.runPass(); + } else { + while (this._running) { + await this._running; + } + } + while (this._running) { + await this._running; + } + await this._storageService.whenIdle(); + } + + override dispose(): void { + this._periodic = false; + this._cancellation.cancel(); + super.dispose(); + } + + private _startRun(run: () => Promise): Promise { + this._rerunRequested = false; + const running = run().finally(() => { + if (this._running === running) { + this._running = undefined; + this._scheduleNextPass(); + } + }); + this._running = running; + return running; + } + + private async _runPassLoop(initialPass: () => Promise): Promise { + let report = await initialPass(); + const outcomes = [...report.outcomes]; + while (this._rerunRequested && !this._cancellation.token.isCancellationRequested) { + this._rerunRequested = false; + report = await this._runSinglePass(this._cancellation.token); + outcomes.push(...report.outcomes); + } + return { outcomes, cursor: report.cursor }; + } + + private async _runSinglePass(token: CancellationToken): Promise { + await this._ensureInitialPayloadDirtyMark(); + if (this._now() - this._lastFullVerification >= this._fullVerificationIntervalMs) { + await this._markAllPayloadsDirty(); + this._lastFullVerification = this._now(); + } + const { sessions, receiptBySession } = await this._listDirtySessions(); + if (sessions.length === 0) { + this._storageService.delete(this._cursorStorageKey); + return { outcomes: [], cursor: undefined }; + } + + const selected = this._selectBatch(sessions, this._readCursor()); + const outcomes = await this._runBatch(selected, receiptBySession, token); + const cursor = selected.at(-1)?.session.toString(); + if (cursor && !token.isCancellationRequested) { + this._storageService.set(this._cursorStorageKey, cursor); + } + return { outcomes, cursor }; + } + + private async _runFullPass(token: CancellationToken): Promise { + const { sessions, receiptBySession } = await this._listDirtySessions(); + const outcomes: AgentHostCatalogReconciliationOutcome[] = []; + let cursor: string | undefined; + for (let index = 0; index < sessions.length && !token.isCancellationRequested; index += this._batchSize) { + const selected = sessions.slice(index, index + this._batchSize); + outcomes.push(...await this._runBatch(selected, receiptBySession, token)); + cursor = selected.at(-1)?.session.toString(); + if (cursor && !token.isCancellationRequested) { + this._storageService.set(this._cursorStorageKey, cursor); + } + } + if (sessions.length === 0) { + this._storageService.delete(this._cursorStorageKey); + } + return { outcomes, cursor }; + } + + private async _listDirtySessions(): Promise<{ + readonly sessions: readonly IRegisteredSession[]; + readonly receiptBySession: ReadonlyMap; + }> { + const [listedSessions, initialReceipts] = await Promise.all([ + this._listSessions(), + this._catalogDatabase.listSessionsV2Receipts(), + ]); + const receiptBySession = new Map(initialReceipts.map(receipt => [receipt.session, receipt])); + const sessions = [...listedSessions] + .filter(session => receiptBySession.get(session.session.toString())?.payloadDirty !== 0) + .sort((first, second) => compareSessionKeys(first.session.toString(), second.session.toString())); + return { sessions, receiptBySession }; + } + + private _runBatch( + selected: readonly IRegisteredSession[], + receiptBySession: ReadonlyMap, + token: CancellationToken, + ): Promise { + const limiter = new Limiter(this._concurrency); + return Promise.all(selected.map(registered => limiter.queue(() => this._reconcileSession( + registered, + receiptBySession.get(registered.session.toString()), + token, + )))); + } + + private async _reconcileSession(registered: IRegisteredSession, receipt: IAgentHostDatabaseSessionV2Receipt | undefined, token: CancellationToken): Promise { + const session = registered.session; + const sessionKey = session.toString(); + try { + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + if (await this._catalogDatabase.isSessionTombstoned(sessionKey)) { + return { session: sessionKey, status: 'retry', reason: 'tombstoned' }; + } + const observedDirty = receipt?.payloadDirty ?? await this._catalogDatabase.getSessionV2PayloadDirty(sessionKey); + + return await this._catalogSyncService.runMigrationExclusive(session, async (database, synchronize) => { + if (!database) { + let result: AgentHostCatalogSyncResult; + const validate = async (): Promise => { + if (!receiptsEqual(receipt, await this._catalogDatabase.getSessionV2(sessionKey)) + || await this._catalogDatabase.getSessionV2PayloadDirty(sessionKey) !== observedDirty) { + throw new CatalogReconciliationSupersededError(); + } + }; + try { + await validate(); + const sourceResult = await this._resolveSource(registered, database); + if (sourceResult.status === 'providerUnavailable') { + throw new CatalogReconciliationProviderUnavailableError(); + } + await validate(); + result = await synchronize(sourceResult.request, validate); + } catch (error) { + if (error instanceof CatalogReconciliationSupersededError) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + if (error instanceof CatalogReconciliationProviderUnavailableError) { + return { session: sessionKey, status: 'retry', reason: 'providerUnavailable' }; + } + throw error; + } + if (result.status === 'pending') { + return { session: sessionKey, status: 'pending', reason: result.reason, sourceRevision: result.sourceRevision }; + } + if (!await this._markPayloadClean(sessionKey, receipt, observedDirty)) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + return { session: sessionKey, status: 'succeeded', reason: 'synchronized', sourceRevision: result.sourceRevision }; + } + const replay = await (async (): Promise => { + const latestReceipt = await this._catalogDatabase.getSessionV2(sessionKey); + if (!receiptsEqual(receipt, latestReceipt) + || await this._catalogDatabase.getSessionV2PayloadDirty(sessionKey) !== observedDirty) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + const snapshot = await database.object.getCatalogSyncSnapshot(); + // A pending snapshot written by a *different* build carries that + // build's projection, which this build cannot replay verbatim. + // It is still evidence that the central row is stale, so the + // session falls through to a full re-projection from its own + // metadata instead of being reported as malformed — otherwise a + // downgrade would leave the older build's writes unreachable + // forever, since the central row it could not update stays + // valid and keeps serving the pre-downgrade values. + const replayable = snapshot?.state === 'pending' && snapshot.projectionVersion === AGENT_HOST_CATALOG_PAYLOAD_VERSION; + if (snapshot?.state === 'pending' && !replayable) { + this._logService.trace(`[AgentHostCatalogReconciliation] Pending snapshot for ${sessionKey} uses projection version ${snapshot.projectionVersion}; re-projecting instead of replaying`); + } + if (replayable) { + const current = await database.object.getCatalogSyncSnapshot(); + const outcome = current?.state !== 'pending' + ? { + session: sessionKey, + status: 'succeeded', + reason: 'pendingReplayed', + sourceRevision: current?.sourceRevision ?? snapshot.sourceRevision, + } satisfies Extract + : await this._replayPending(session, current, acknowledgement => database.object.acknowledgeCatalogSyncSnapshot(acknowledgement), token); + if (outcome.status === 'succeeded') { + if (!await this._markPayloadClean(sessionKey, latestReceipt, observedDirty)) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + return outcome; + } + if (outcome.status !== 'retry' || (outcome.reason !== 'staleIncarnation' && outcome.reason !== 'missingCatalog')) { + return outcome; + } + } + return undefined; + })(); + if (replay) { + return replay; + } + + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + const sourceResult = await this._resolveSource(registered, database); + if (sourceResult.status === 'providerUnavailable') { + return { session: sessionKey, status: 'retry', reason: 'providerUnavailable' }; + } + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + const legacyMetadataMatches = await catalogLegacyMetadataMatches(database.object, sourceResult.request.legacyMetadata); + const expected = encodeAgentHostCatalogPayload(sourceResult.request.data); + return await (async (): Promise => { + const latestReceipt = await this._catalogDatabase.getSessionV2(sessionKey); + if (!receiptsEqual(receipt, latestReceipt) + || await this._catalogDatabase.getSessionV2PayloadDirty(sessionKey) !== observedDirty) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + const currentSnapshot = await database.object.getCatalogSyncSnapshot(); + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + + const central = await this._catalogDatabase.getSessionV2(sessionKey); + if (legacyMetadataMatches + && expected.ok + && currentSnapshot?.payloadHash === expected.value.payloadHash + && matchesAcknowledgedCatalogReceipt(currentSnapshot, central) + && this._isValidCentralPayload(central)) { + if (!await this._markPayloadClean(sessionKey, receipt, observedDirty)) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + return { session: sessionKey, status: 'skipped', reason: 'synchronized' }; + } + if (legacyMetadataMatches + && expected.ok + && currentSnapshot?.payloadHash === expected.value.payloadHash + && matchesAcknowledgedCatalogReceipt(currentSnapshot, central) + && central) { + const replacement: ISessionCatalogSyncPendingSnapshot = { + sessionGeneration: central.sessionGeneration, + sourceRevision: central.sourceRevision + 1, + projectionVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + payload: expected.value.payload, + payloadHash: expected.value.payloadHash, + state: 'pending', + }; + await database.object.setMetadataValuesAndCatalogSyncSnapshot(sourceResult.request.legacyMetadata, replacement); + const pending = await database.object.getCatalogSyncSnapshot(); + if (pending?.state !== 'pending' + || pending.sessionGeneration !== replacement.sessionGeneration + || pending.sourceRevision !== replacement.sourceRevision + || pending.projectionVersion !== replacement.projectionVersion + || pending.payloadHash !== replacement.payloadHash + || pending.payload !== replacement.payload) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + const outcome = await this._replayPending(session, pending, acknowledgement => database.object.acknowledgeCatalogSyncSnapshot(acknowledgement), token); + if (outcome.status !== 'succeeded') { + return outcome; + } + if (!await this._markPayloadClean(sessionKey, latestReceipt, observedDirty)) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + return { session: sessionKey, status: 'succeeded', reason: 'synchronized', sourceRevision: outcome.sourceRevision }; + } + + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + if (await this._catalogDatabase.isSessionTombstoned(sessionKey)) { + return { session: sessionKey, status: 'retry', reason: 'tombstoned' }; + } + const synchronized = await synchronize(sourceResult.request); + if (synchronized.status !== 'acknowledged') { + return { session: sessionKey, status: 'pending', reason: synchronized.reason, sourceRevision: synchronized.sourceRevision }; + } + if (!await this._markPayloadClean(sessionKey, receipt, observedDirty)) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + return { session: sessionKey, status: 'succeeded', reason: 'synchronized', sourceRevision: synchronized.sourceRevision }; + })(); + }); + } catch (error) { + if (error instanceof AgentHostCatalogDeletionFencedError) { + return { session: sessionKey, status: 'retry', reason: 'tombstoned' }; + } + this._logService.warn(`[AgentHostCatalogReconciliation] Failed to reconcile ${sessionKey}`, error); + return { session: sessionKey, status: 'failed', reason: 'unexpected', error: error instanceof Error ? error.message : String(error) }; + } + } + + private _isValidCentralPayload(central: IAgentHostDatabaseSessionV2 | undefined): boolean { + if (!central || central.payloadVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION) { + return false; + } + const decoded = decodeAgentHostCatalogPayload(central.payload); + return decoded.ok + && decoded.value.payload === central.payload + && hashAgentHostCatalogPayload(central.payload) === central.payloadHash; + } + + private async _replayPending( + session: URI, + snapshot: ISessionCatalogSyncPendingSnapshot, + acknowledge: (acknowledgement: ISessionCatalogSyncAcknowledgement) => Promise, + token: CancellationToken, + ): Promise> { + const sessionKey = session.toString(); + const decoded = decodeAgentHostCatalogPayload(snapshot.payload); + if (!decoded.ok || snapshot.projectionVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION) { + return { session: sessionKey, status: 'failed', reason: 'malformedPayload', error: decoded.ok ? 'Unsupported payload version' : decoded.error }; + } + if (decoded.value.payload !== snapshot.payload || hashAgentHostCatalogPayload(snapshot.payload) !== snapshot.payloadHash) { + return { session: sessionKey, status: 'failed', reason: 'payloadMismatch', error: 'Pending payload is not canonical or its hash does not match' }; + } + let central = await this._catalogDatabase.getSessionV2(sessionKey); + if (central && central.sessionGeneration !== snapshot.sessionGeneration) { + return { session: sessionKey, status: 'retry', reason: 'staleIncarnation' }; + } + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + if (await this._catalogDatabase.isSessionTombstoned(sessionKey)) { + return { session: sessionKey, status: 'retry', reason: 'tombstoned' }; + } + central = await this._catalogDatabase.getSessionV2(sessionKey); + if (central && central.sessionGeneration !== snapshot.sessionGeneration) { + return { session: sessionKey, status: 'retry', reason: 'staleIncarnation' }; + } + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + + let applyResult: AgentHostDatabaseSessionV2UpsertResult; + try { + applyResult = await this._catalogDatabase.upsertSessionV2({ + session: sessionKey, + sessionGeneration: snapshot.sessionGeneration, + sourceRevision: snapshot.sourceRevision, + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + payloadHash: snapshot.payloadHash, + verified: true, + payload: snapshot.payload, + }, central?.sessionGeneration); + } catch (error) { + return { session: sessionKey, status: 'pending', reason: 'upsertFailed', sourceRevision: snapshot.sourceRevision }; + } + if (applyResult !== 'applied' && applyResult !== 'replayed') { + return this._applyFailure(sessionKey, applyResult); + } + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + if (!await acknowledge(snapshot)) { + return { session: sessionKey, status: 'failed', reason: 'acknowledgementSuperseded' }; + } + return { session: sessionKey, status: 'succeeded', reason: 'pendingReplayed', sourceRevision: snapshot.sourceRevision }; + } + + private _applyFailure(session: string, result: AgentHostDatabaseSessionV2UpsertResult): Extract { + if (result === 'tombstoned') { + return { session, status: 'retry', reason: 'tombstoned' }; + } + if (result === 'generationMismatch') { + return { session, status: 'retry', reason: 'staleIncarnation' }; + } + if (result === 'missingSession') { + return { session, status: 'retry', reason: 'missingCatalog' }; + } + if (result === 'stale' || result === 'conflict') { + return { session, status: 'retry', reason: 'superseded' }; + } + return { session, status: 'failed', reason: 'centralApplyFailed', error: result }; + } + + private async _markPayloadClean(session: string, receipt: IAgentHostDatabaseSessionV2Receipt | undefined, expectedDirty = receipt?.payloadDirty): Promise { + const current = await this._catalogDatabase.getSessionV2(session); + if (!current) { + return false; + } + if (expectedDirty === undefined || expectedDirty === 0) { + return current.payloadDirty === 0; + } + return this._catalogDatabase.markSessionV2PayloadClean(session, expectedDirty); + } + + private async _ensureInitialPayloadDirtyMark(): Promise { + if (!this._initialPayloadDirtyMarkPending) { + return; + } + await this._markAllPayloadsDirty(); + this._initialPayloadDirtyMarkPending = false; + this._lastFullVerification = this._now(); + } + + private async _prepareFullVerification(): Promise { + await this._markAllPayloadsDirty(); + this._initialPayloadDirtyMarkPending = false; + this._lastFullVerification = this._now(); + } + + private _markAllPayloadsDirty(): Promise { + if (!this._payloadDirtyMark) { + const operation = this._catalogDatabase.markAllSessionsV2PayloadsDirty(); + const tracked = operation.finally(() => { + if (this._payloadDirtyMark === tracked) { + this._payloadDirtyMark = undefined; + } + }); + this._payloadDirtyMark = tracked; + } + return this._payloadDirtyMark; + } + + private _selectBatch(sessions: readonly IRegisteredSession[], cursor: string | undefined): readonly IRegisteredSession[] { + const start = cursor === undefined ? 0 : Math.max(0, sessions.findIndex(session => compareSessionKeys(session.session.toString(), cursor) > 0)); + const ordered = start === 0 ? sessions : [...sessions.slice(start), ...sessions.slice(0, start)]; + return ordered.slice(0, this._batchSize); + } + + private _readCursor(): string | undefined { + const cursor = this._storageService.get(this._cursorStorageKey); + return typeof cursor === 'string' ? cursor : undefined; + } + + private _scheduleNextPass(): void { + if (!this._periodic || this._running || this._scheduledPassKind || this._cancellation.token.isCancellationRequested) { + return; + } + this._schedulePass('periodic', this._intervalMs); + } + + private _schedulePass(kind: ScheduledPassKind, delay: number): void { + this._scheduledPass.clear(); + this._scheduledPassKind = kind; + this._scheduledPass.value = this._schedule(() => { + this._scheduledPass.clear(); + this._scheduledPassKind = undefined; + this.start(); + }, delay); + } + + private _logOutcomes(outcomes: readonly AgentHostCatalogReconciliationOutcome[]): void { + for (const outcome of outcomes) { + if (outcome.status === 'failed') { + this._logService.warn(`[AgentHostCatalogReconciliation] ${outcome.session} failed: ${outcome.reason}${outcome.error ? ` (${outcome.error})` : ''}`); + } else if (outcome.status === 'pending' || outcome.status === 'retry') { + this._logService.info(`[AgentHostCatalogReconciliation] ${outcome.session} will be retried: ${outcome.reason}`); + } + } + } + + private _positiveInteger(value: number | undefined, fallback: number, name: string): number { + if (value === undefined) { + return fallback; + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Catalog reconciliation ${name} must be a positive safe integer`); + } + return value; + } + + private _nonNegativeInteger(value: number | undefined, fallback: number, name: string): number { + if (value === undefined) { + return fallback; + } + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Catalog reconciliation ${name} must be a non-negative safe integer`); + } + return value; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts new file mode 100644 index 00000000000000..4be443418854ff --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -0,0 +1,442 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY, readAgentDevContainerWorktreeMetadata } from '../common/meta/agentDevContainerWorktreeMeta.js'; +import { parseSessionArtifacts, readSessionArtifacts, SESSION_META_ARTIFACTS_KEY, stringifySessionArtifacts } from '../common/sessionArtifacts.js'; +import { META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; +import { META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; +import { ChangesSummary, ChatOrigin, ChatOriginKind } from '../common/state/protocol/state.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ISessionGitHubState, ISessionGitState, ISessionSourceControlState, parseSessionCreationReference, parseSessionFolderPickerDecision, parseSessionMultiRootMetadata, readSessionCreationReference, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionStatus, SessionSummary } from '../common/state/sessionState.js'; +import { AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT, AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, agentHostCatalogChangesValidator, agentHostCatalogGitValidator } from './agentHostCatalogProjection.js'; +import { IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; +import { AGENT_HOST_TITLE_SOURCE_AUTO, AgentHostTitleSource, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { WORKTREE_META_REPOSITORY_ROOT } from './shared/worktreeIsolation.js'; + +export const CHAT_BACKING_METADATA_KEY = 'peerChatBacking'; + +export interface ICatalogSourceState { + readonly modifiedTime: number; + readonly title?: string; + readonly status: SessionStatus; + readonly project?: { readonly uri: string; readonly displayName: string }; + readonly workingDirectories: readonly string[]; + readonly changes?: ChangesSummary; + readonly meta?: SessionSummary['_meta']; + readonly chats: readonly { + readonly uri: string; + readonly kind: 'default' | 'peer'; + readonly title?: string; + readonly origin?: ChatOrigin; + readonly inheritedTurnId?: string; + }[]; +} + +export interface IAgentHostCatalogSourceResolverDependencies { + readonly isUnpersistedChatBacking: (session: URI) => boolean; + readonly worktreeProjectFromRepositoryRoot: (repositoryRoot: string | undefined) => { readonly uri: URI; readonly displayName: string } | undefined; +} + +export interface IAgentHostCatalogMetadataReference { + readonly object: { + getMetadataObject>(keys: T): Promise<{ [K in keyof T]: string | undefined }>; + }; +} + +interface ISessionMetadataKey { + readonly key: string; +} + +interface ITypedSessionMetadataKey extends ISessionMetadataKey { + has(values: Readonly>): boolean; + read(values: Readonly>): T | undefined; +} + +function stringSessionMetadataKey(key: string): ITypedSessionMetadataKey { + return { + key, + has: values => values[key] !== undefined, + read: values => values[key], + }; +} + +function parsedSessionMetadataKey(key: string, parse: (value: string) => T | undefined): ITypedSessionMetadataKey { + return { + key, + has: values => values[key] !== undefined, + read: values => { + const value = values[key]; + return value === undefined ? undefined : parse(value); + }, + }; +} + +const sessionMetadata = { + title: stringSessionMetadataKey(SESSION_CUSTOM_TITLE_KEY), + titleSource: stringSessionMetadataKey(SESSION_CUSTOM_TITLE_SOURCE_KEY), + isRead: parsedSessionMetadataKey(AH_META_IS_READ_DB_KEY, value => value === 'true'), + isArchived: parsedSessionMetadataKey(AH_META_IS_ARCHIVED_DB_KEY, value => value === 'true'), + isDone: parsedSessionMetadataKey(AH_META_IS_DONE_DB_KEY, value => value === 'true'), + creationReference: parsedSessionMetadataKey(AH_META_CREATED_BY_SESSION_DB_KEY, parseSessionCreationReference), + workspaceless: parsedSessionMetadataKey(AH_META_WORKSPACELESS_DB_KEY, value => value === 'true'), + ehcliAdopted: parsedSessionMetadataKey(AH_META_EHCLI_ADOPTED_DB_KEY, value => value === 'true'), + multiRoot: parsedSessionMetadataKey(SESSION_META_MULTI_ROOT_KEY, parseSessionMultiRootMetadata), + folderPicker: parsedSessionMetadataKey(SESSION_META_FOLDER_PICKER_KEY, parseSessionFolderPickerDecision), + artifacts: parsedSessionMetadataKey(SESSION_ARTIFACTS_KEY, value => parseSessionArtifacts(value).artifacts), + changes: parsedSessionMetadataKey(META_CHANGES_SUMMARY, readPersistedChanges), + chatBacking: stringSessionMetadataKey(CHAT_BACKING_METADATA_KEY), + worktreeRepositoryRoot: stringSessionMetadataKey(WORKTREE_META_REPOSITORY_ROOT), + gitHub: parsedSessionMetadataKey(META_GITHUB_STATE, readPersistedGitHubState), + git: parsedSessionMetadataKey(META_GIT_STATE, readPersistedGitState), + sourceControl: parsedSessionMetadataKey(META_SOURCE_CONTROL_STATE, readPersistedSourceControlState), + devContainerWorktree: parsedSessionMetadataKey(AH_META_DEV_CONTAINER_WORKTREE_DB_KEY, readPersistedDevContainerWorktree), +} as const; + +const sessionMetadataKeys: readonly ISessionMetadataKey[] = Object.values(sessionMetadata); + +function createMetadataKeySet(keys: readonly ISessionMetadataKey[]): Record { + return keys.reduce>((result, metadata) => { + result[metadata.key] = true; + return result; + }, {}); +} + +export class AgentHostCatalogSourceResolver { + + constructor(private readonly _dependencies: IAgentHostCatalogSourceResolverDependencies) { } + + async buildCatalogSyncRequest( + session: URI, + state: ICatalogSourceState, + metadataOverrides: Readonly>, + preferPersistedMetadata: boolean, + database: IAgentHostCatalogMetadataReference | undefined = undefined, + metadataFallbacks: Readonly> = {}, + ): Promise { + const metadataKeys = createMetadataKeySet(sessionMetadataKeys); + for (const chat of state.chats) { + metadataKeys[customChatTitleMetadataKey(chat.uri)] = true; + metadataKeys[customChatTitleSourceMetadataKey(chat.uri)] = true; + } + + const persisted: { readonly [key: string]: string | undefined } = database + ? await database.object.getMetadataObject(metadataKeys) + : {}; + const metadata = { ...metadataFallbacks, ...persisted, ...metadataOverrides }; + const persistedTitle = sessionMetadata.title.read(metadata); + const persistedTitleSource = sessionMetadata.titleSource.read(metadata); + const title = preferPersistedMetadata + ? persistedTitle ?? state.title ?? '' + : metadataOverrides[SESSION_CUSTOM_TITLE_KEY] ?? state.title ?? ''; + const titleSource = normalizeCatalogTitleSource(persistedTitleSource); + const persistedMultiRoot = sessionMetadata.multiRoot.read(metadata); + const multiRoot = preferPersistedMetadata + ? (sessionMetadata.multiRoot.has(metadata) ? persistedMultiRoot : readSessionMultiRootMetadata(state.meta)) + : readSessionMultiRootMetadata(state.meta) ?? persistedMultiRoot; + const persistedFolderPicker = sessionMetadata.folderPicker.read(metadata); + const folderPicker = preferPersistedMetadata + ? (sessionMetadata.folderPicker.has(metadata) ? persistedFolderPicker : readSessionFolderPickerDecision(state.meta)) + : readSessionFolderPickerDecision(state.meta) ?? persistedFolderPicker; + const persistedArtifacts = sessionMetadata.artifacts.read(metadata) ?? []; + const stateArtifacts = readSessionArtifacts(state.meta); + const artifacts = preferPersistedMetadata + ? (metadata[SESSION_ARTIFACTS_KEY] !== undefined ? persistedArtifacts : stateArtifacts) + : (metadataOverrides[SESSION_ARTIFACTS_KEY] !== undefined || stateArtifacts.length === 0 ? persistedArtifacts : stateArtifacts); + const persistedCreationReference = sessionMetadata.creationReference.read(metadata); + const creationReference = preferPersistedMetadata + ? (sessionMetadata.creationReference.has(metadata) ? persistedCreationReference : readSessionCreationReference(state.meta)) + : readSessionCreationReference(state.meta) ?? persistedCreationReference; + const persistedGitHub = sessionMetadata.gitHub.read(metadata); + const github = preferPersistedMetadata + ? (sessionMetadata.gitHub.has(metadata) ? persistedGitHub : readSessionGitHubState(state.meta)) + : readSessionGitHubState(state.meta) ?? persistedGitHub; + const persistedSourceControl = sessionMetadata.sourceControl.read(metadata); + const sourceControl = preferPersistedMetadata + ? (sessionMetadata.sourceControl.has(metadata) ? persistedSourceControl : readSessionSourceControlState(state.meta)) + : readSessionSourceControlState(state.meta) ?? persistedSourceControl; + const persistedGit = sessionMetadata.git.read(metadata); + const git = readSessionGitState(state.meta) ?? persistedGit; + const persistedWorkspaceless = sessionMetadata.workspaceless.read(metadata) ?? false; + const workspaceless = preferPersistedMetadata && metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined + ? persistedWorkspaceless + : readSessionWorkspaceless(state.meta) || persistedWorkspaceless; + const stateIsRead = (state.status & SessionStatus.IsRead) !== 0; + const isRead = preferPersistedMetadata && metadata[AH_META_IS_READ_DB_KEY] !== undefined + ? sessionMetadata.isRead.read(metadata) ?? false + : stateIsRead; + const persistedArchived = sessionMetadata.isArchived.read(metadata) ?? sessionMetadata.isDone.read(metadata); + const isArchived = preferPersistedMetadata && persistedArchived !== undefined + ? persistedArchived + : (state.status & SessionStatus.IsArchived) !== 0; + const persistedChanges = sessionMetadata.changes.read(metadata); + const changes = preferPersistedMetadata && metadata[META_CHANGES_SUMMARY] !== undefined ? persistedChanges : state.changes; + const worktreeProject = this._dependencies.worktreeProjectFromRepositoryRoot(sessionMetadata.worktreeRepositoryRoot.read(metadata)); + const ehcliAdoptable = readSessionEhcliAdoptable(state.meta); + const ehcliAdopted = readSessionEhcliAdopted(state.meta) || sessionMetadata.ehcliAdopted.read(metadata) === true; + const persistedDevContainerWorktree = sessionMetadata.devContainerWorktree.read(metadata); + const devContainerWorktree = preferPersistedMetadata && sessionMetadata.devContainerWorktree.has(metadata) + ? persistedDevContainerWorktree + : readAgentDevContainerWorktreeMetadata(state.meta) ?? persistedDevContainerWorktree; + const meta: AgentHostCatalogMetadata = { + ...(multiRoot ? { [SESSION_META_MULTI_ROOT_KEY]: multiRoot } : undefined), + ...(folderPicker ? { [SESSION_META_FOLDER_PICKER_KEY]: folderPicker } : undefined), + ...(github ? { [SESSION_META_GITHUB_KEY]: github } : undefined), + ...(git ? { [SESSION_META_GIT_KEY]: git } : undefined), + ...(sourceControl ? { [SESSION_META_SOURCE_CONTROL_KEY]: sourceControl } : undefined), + ...(artifacts.length > 0 ? { [SESSION_META_ARTIFACTS_KEY]: [...artifacts] } : undefined), + ...(creationReference ? { [SESSION_META_CREATED_BY_SESSION_KEY]: creationReference } : undefined), + ...(workspaceless ? { [SESSION_META_WORKSPACELESS_KEY]: true } : undefined), + ...(ehcliAdoptable ? { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true } : undefined), + ...(ehcliAdopted ? { [SESSION_META_EHCLI_ADOPTED_KEY]: true } : undefined), + ...(devContainerWorktree ? { [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: devContainerWorktree } : undefined), + }; + const data: AgentHostCatalogData = { + modifiedTime: state.modifiedTime, + summary: toCatalogSummary(title), + titleSource, + isRead, + isArchived, + project: worktreeProject + ? { uri: worktreeProject.uri.toString(), displayName: worktreeProject.displayName } + : state.project, + isChatBacking: !!sessionMetadata.chatBacking.read(metadata) || this._dependencies.isUnpersistedChatBacking(session), + workingDirectories: state.workingDirectories, + changes, + _meta: Object.keys(meta).length > 0 ? meta : undefined, + chats: state.chats.map((chat, order) => { + const summary = preferPersistedMetadata + ? metadata[customChatTitleMetadataKey(chat.uri)] + || chat.title + || undefined + : metadataOverrides[customChatTitleMetadataKey(chat.uri)] + || chat.title + || metadata[customChatTitleMetadataKey(chat.uri)] + || undefined; + const titleSource = preferPersistedMetadata + ? metadata[customChatTitleSourceMetadataKey(chat.uri)] + : metadataOverrides[customChatTitleSourceMetadataKey(chat.uri)] ?? metadata[customChatTitleSourceMetadataKey(chat.uri)]; + return { + uri: chat.uri, + order, + kind: chat.kind, + summary: toCatalogSummary(summary), + titleSource: normalizeCatalogTitleSource(titleSource), + origin: toCatalogChatOrigin(chat.origin), + ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), + }; + }), + }; + const legacyMetadata: Record = { + ...metadataOverrides, + [AH_META_IS_READ_DB_KEY]: data.isRead ? 'true' : '', + [AH_META_IS_ARCHIVED_DB_KEY]: data.isArchived ? 'true' : '', + [SESSION_META_MULTI_ROOT_KEY]: multiRoot ? JSON.stringify(multiRoot) : '', + [SESSION_META_FOLDER_PICKER_KEY]: folderPicker ? JSON.stringify(folderPicker) : '', + [SESSION_ARTIFACTS_KEY]: stringifySessionArtifacts(artifacts), + }; + if (creationReference || metadata[AH_META_CREATED_BY_SESSION_DB_KEY] !== undefined) { + legacyMetadata[AH_META_CREATED_BY_SESSION_DB_KEY] = creationReference ? JSON.stringify(creationReference) : ''; + } + if (workspaceless || metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { + legacyMetadata[AH_META_WORKSPACELESS_DB_KEY] = workspaceless ? 'true' : 'false'; + } + if (metadata[CHAT_BACKING_METADATA_KEY] !== undefined) { + legacyMetadata[CHAT_BACKING_METADATA_KEY] = metadata[CHAT_BACKING_METADATA_KEY]; + } + if (metadata[WORKTREE_META_REPOSITORY_ROOT] !== undefined) { + legacyMetadata[WORKTREE_META_REPOSITORY_ROOT] = metadata[WORKTREE_META_REPOSITORY_ROOT]; + } + if (devContainerWorktree || sessionMetadata.devContainerWorktree.has(metadata)) { + legacyMetadata[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY] = devContainerWorktree ? JSON.stringify(devContainerWorktree) : ''; + } + if (metadataOverrides[SESSION_CUSTOM_TITLE_KEY] !== undefined || persisted[SESSION_CUSTOM_TITLE_KEY] !== undefined) { + legacyMetadata[SESSION_CUSTOM_TITLE_KEY] = title; + legacyMetadata[SESSION_CUSTOM_TITLE_SOURCE_KEY] = titleSource; + } else if (metadataOverrides[SESSION_CUSTOM_TITLE_SOURCE_KEY] !== undefined || persisted[SESSION_CUSTOM_TITLE_SOURCE_KEY] !== undefined) { + legacyMetadata[SESSION_CUSTOM_TITLE_SOURCE_KEY] = titleSource; + } + if (github) { + legacyMetadata[META_GITHUB_STATE] = JSON.stringify(github); + } + if (sourceControl) { + legacyMetadata[META_SOURCE_CONTROL_STATE] = JSON.stringify(sourceControl); + } + if (git) { + legacyMetadata[META_GIT_STATE] = JSON.stringify(git); + } else if (metadata[META_GIT_STATE] !== undefined) { + legacyMetadata[META_GIT_STATE] = ''; + } + if (metadata[META_CHANGES_SUMMARY] !== undefined) { + legacyMetadata[META_CHANGES_SUMMARY] = changes ? JSON.stringify(changes) : ''; + } + return { data, legacyMetadata }; + } +} + +function toCatalogSummary(value: string | undefined): string | undefined { + if (!value || value.length <= AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT) { + return value || undefined; + } + let end = AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT - 1; + const lastCodeUnit = value.charCodeAt(end - 1); + if (lastCodeUnit >= 0xD800 && lastCodeUnit <= 0xDBFF) { + end--; + } + return `${value.slice(0, end)}…`; +} + +export function toSerializableJsonValue(value: unknown): AgentHostCatalogJsonValue | undefined { + if (value === undefined) { + return undefined; + } + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined; + } + if (Array.isArray(value)) { + const result: AgentHostCatalogJsonValue[] = []; + for (const entry of value) { + const converted = toSerializableJsonValue(entry); + if (converted !== undefined) { + result.push(converted); + } + } + return result; + } + if (typeof value === 'object') { + const result: { [key: string]: AgentHostCatalogJsonValue } = {}; + for (const [key, entry] of Object.entries(value)) { + const converted = toSerializableJsonValue(entry); + if (converted !== undefined) { + result[key] = converted; + } + } + return result; + } + return undefined; +} + +/** Projects bounded navigation provenance while authoritative selection snapshots remain in peer-chat metadata. */ +function toCatalogChatOrigin(origin: ChatOrigin | undefined): AgentHostCatalogJsonValue | undefined { + if (!origin) { + return undefined; + } + const projected = origin.kind === ChatOriginKind.SideChat + ? { kind: origin.kind, chat: origin.chat, turnId: origin.turnId } + : origin; + const value = toSerializableJsonValue(projected); + return value !== undefined && hasOnlyBoundedStrings(value) ? value : undefined; +} + +function hasOnlyBoundedStrings(value: AgentHostCatalogJsonValue): boolean { + if (typeof value === 'string') { + return value.length <= AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT; + } + if (Array.isArray(value)) { + return value.every(hasOnlyBoundedStrings); + } + if (value && typeof value === 'object') { + return Object.entries(value).every(([key, entry]) => + key.length <= AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT && hasOnlyBoundedStrings(entry)); + } + return true; +} + +export function fromCatalogChatOrigin(value: AgentHostCatalogJsonValue | undefined): ChatOrigin | undefined { + if (!isRecord(value) || typeof value.kind !== 'string') { + return undefined; + } + if (value.kind === ChatOriginKind.User) { + return { kind: ChatOriginKind.User }; + } + if (typeof value.chat !== 'string') { + return undefined; + } + if (value.kind === ChatOriginKind.Fork && typeof value.turnId === 'string') { + return { kind: ChatOriginKind.Fork, chat: value.chat, turnId: value.turnId }; + } + if (value.kind === ChatOriginKind.SideChat && typeof value.turnId === 'string') { + const selection = isRecord(value.selection) + && typeof value.selection.text === 'string' + && (value.selection.responsePartId === undefined || typeof value.selection.responsePartId === 'string') + ? { + text: value.selection.text, + ...(typeof value.selection.responsePartId === 'string' ? { responsePartId: value.selection.responsePartId } : {}), + } + : undefined; + return { + kind: ChatOriginKind.SideChat, + chat: value.chat, + turnId: value.turnId, + ...(selection ? { selection } : {}), + }; + } + if (value.kind === ChatOriginKind.Tool && typeof value.toolCallId === 'string') { + return { kind: ChatOriginKind.Tool, chat: value.chat, toolCallId: value.toolCallId }; + } + return undefined; +} + +function normalizeCatalogTitleSource(value: string | undefined): AgentHostTitleSource { + return value === 'user' || value === 'agent' || value === 'auto' ? value : AGENT_HOST_TITLE_SOURCE_AUTO; +} + +function readPersistedGitHubState(value: string | undefined): ISessionGitHubState | undefined { + if (!value) { + return undefined; + } + try { + return readSessionGitHubState({ [SESSION_META_GITHUB_KEY]: JSON.parse(value) }); + } catch { + return undefined; + } +} + +function readPersistedDevContainerWorktree(value: string): ReturnType { + try { + return readAgentDevContainerWorktreeMetadata({ [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: JSON.parse(value) }); + } catch { + return undefined; + } +} + +function readPersistedSourceControlState(value: string | undefined): ISessionSourceControlState | undefined { + if (!value) { + return undefined; + } + try { + return readSessionSourceControlState({ [SESSION_META_SOURCE_CONTROL_KEY]: JSON.parse(value) }); + } catch { + return undefined; + } +} + +function readPersistedGitState(value: string | undefined): ISessionGitState | undefined { + if (!value) { + return undefined; + } + try { + return agentHostCatalogGitValidator.validate(JSON.parse(value)).content; + } catch { + return undefined; + } +} + +function readPersistedChanges(value: string | undefined): ChangesSummary | undefined { + if (!value) { + return undefined; + } + try { + return agentHostCatalogChangesValidator.validate(JSON.parse(value)).content; + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts new file mode 100644 index 00000000000000..72655b1c00e4c5 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts @@ -0,0 +1,447 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../base/common/uuid.js'; +import { URI } from '../../../base/common/uri.js'; +import { SequencerByKey } from '../../../base/common/async.js'; +import { createSingleCallFunction } from '../../../base/common/functional.js'; +import { type IDisposable, type IReference } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionCatalogSyncSnapshot, ISessionDataService, ISessionDatabase } from '../common/sessionDataService.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, AgentHostCatalogData, encodeAgentHostCatalogPayload, IAgentHostCatalogEncodedPayload } from './agentHostCatalogProjection.js'; +import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; + +const INITIAL_SOURCE_REVISION = 0; +const MAX_GENERATION_RETRIES = 3; + +export interface IAgentHostCatalogSyncRequest { + readonly data: AgentHostCatalogData; + readonly legacyMetadata: Readonly>; +} + +export type AgentHostCatalogDatabaseReference = IReference; + +export type AgentHostCatalogSyncResult = + | { readonly status: 'acknowledged'; readonly sourceRevision: number } + | { readonly status: 'pending'; readonly sourceRevision: number; readonly reason: AgentHostDatabaseSessionV2UpsertResult | 'upsertFailed' | 'acknowledgementSuperseded' }; + +/** A synchronously-established deletion fence whose drain includes previously queued synchronization. */ +export interface IAgentHostCatalogDeletionFence extends IDisposable { + readonly whenDrained: Promise; +} + +export class AgentHostCatalogDeletionFencedError extends Error { + constructor(session: URI) { + super(`Catalog synchronization rejected during session deletion: ${session.toString()}`); + } +} + +/** + * Whether the stored catalog row is exactly the one an acknowledged local + * receipt describes, so the session needs no further synchronization. + */ +export function matchesAcknowledgedCatalogReceipt( + receipt: ISessionCatalogSyncSnapshot | undefined, + catalog: IAgentHostDatabaseSessionV2Receipt | undefined, +): boolean { + return receipt?.state === 'acknowledged' + && catalog?.sessionGeneration === receipt.sessionGeneration + && catalog.sourceRevision === receipt.sourceRevision + && catalog.payloadVersion === receipt.projectionVersion + && catalog.payloadHash === receipt.payloadHash; +} + +/** Whether every legacy compatibility key the request carries is already persisted. */ +export async function catalogLegacyMetadataMatches( + database: ReturnType['object'], + legacyMetadata: Readonly>, +): Promise { + const metadataKeys: Record = {}; + for (const key of Object.keys(legacyMetadata)) { + metadataKeys[key] = true; + } + const persistedMetadata = await database.getMetadataObject(metadataKeys); + return Object.entries(legacyMetadata).every(([key, value]) => persistedMetadata[key] === value); +} + +export class AgentHostCatalogSyncService { + + private readonly _sequencer = new SequencerByKey(); + private readonly _deletionFences = new Map }>(); + + constructor( + private readonly _sessionDataService: ISessionDataService, + private readonly _catalogDatabase: IAgentHostDatabase, + private readonly _logService: ILogService, + ) { } + + isSessionDeletionFenced(session: URI): boolean { + return this._deletionFences.has(session.toString()); + } + + /** Prevents new synchronization and returns a shared per-session queue drain. */ + beginSessionDeletion(session: URI): IAgentHostCatalogDeletionFence { + const sessionKey = session.toString(); + let fence = this._deletionFences.get(sessionKey); + if (fence) { + fence.count++; + } else { + fence = { + count: 1, + whenDrained: this._sequencer.queue(sessionKey, async () => { }), + }; + this._deletionFences.set(sessionKey, fence); + } + const acquiredFence = fence; + const release = createSingleCallFunction(() => { + if (this._deletionFences.get(sessionKey) !== acquiredFence) { + return; + } + acquiredFence.count--; + if (acquiredFence.count === 0) { + this._deletionFences.delete(sessionKey); + } + }); + return { + whenDrained: acquiredFence.whenDrained, + dispose: release, + }; + } + + synchronize(session: URI, request: IAgentHostCatalogSyncRequest): Promise { + return this.runExclusive(session, async synchronize => { + await this._markPayloadDirty(session); + const result = await synchronize(request); + await this._markPayloadDirty(session); + return result; + }); + } + + synchronizeWithFactory(session: URI, requestFactory: (database: AgentHostCatalogDatabaseReference) => Promise): Promise { + return this.runExclusive(session, async (synchronize, database) => { + await this._markPayloadDirty(session); + const result = await synchronize(await requestFactory(database)); + await this._markPayloadDirty(session); + return result; + }); + } + + synchronizeMigrationWithFactory( + session: URI, + requestFactory: (database: AgentHostCatalogDatabaseReference | undefined) => Promise, + validate?: () => Promise, + ): Promise { + return this.runMigrationExclusive(session, async (database, synchronize) => { + const request = await requestFactory(database); + if (database) { + await this._markPayloadDirty(session); + } + const result = await synchronize(request, validate); + if (database) { + await this._markPayloadDirty(session); + } + return result; + }); + } + + runExclusive(session: URI, operation: ( + synchronize: (request: IAgentHostCatalogSyncRequest) => Promise, + database: AgentHostCatalogDatabaseReference, + ) => Promise): Promise { + if (this.isSessionDeletionFenced(session)) { + return Promise.reject(new AgentHostCatalogDeletionFencedError(session)); + } + return this._sequencer.queue( + session.toString(), + async () => { + const database = this._sessionDataService.openDatabase(session); + try { + return await operation(request => this._synchronizeWithDatabaseNow(session, request, database), database); + } finally { + database.dispose(); + } + }, + ); + } + + runMigrationExclusive(session: URI, operation: ( + database: AgentHostCatalogDatabaseReference | undefined, + synchronize: (request: IAgentHostCatalogSyncRequest, validate?: () => Promise) => Promise, + ) => Promise): Promise { + if (this.isSessionDeletionFenced(session)) { + return Promise.reject(new AgentHostCatalogDeletionFencedError(session)); + } + return this._sequencer.queue(session.toString(), async () => { + const database = await this._sessionDataService.tryOpenDatabase(session); + try { + return await operation( + database, + (request, validate) => database + ? this._synchronizeWithDatabaseNow(session, request, database) + : this._synchronizeCentralOnlyNow(session, request, validate), + ); + } finally { + database?.dispose(); + } + }); + } + + private async _synchronizeWithDatabaseNow( + session: URI, + request: IAgentHostCatalogSyncRequest, + ref: ReturnType, + ): Promise { + const sessionKey = session.toString(); + const encoded = this._encode(request.data); + for (let attempt = 0; attempt < MAX_GENERATION_RETRIES; attempt++) { + const existing = await ref.object.getCatalogSyncSnapshot(); + let central: IAgentHostDatabaseSessionV2 | undefined; + try { + central = await this._catalogDatabase.getSessionV2(sessionKey); + } catch (error) { + this._logService.warn(`[AgentHostCatalogSync] Failed to read sessions_v2 row for ${sessionKey}`, error); + const legacyMetadataMatches = await catalogLegacyMetadataMatches(ref.object, request.legacyMetadata); + const pending = await this._storePending(ref.object, request, encoded, existing, legacyMetadataMatches); + return { status: 'pending', sourceRevision: pending.sourceRevision, reason: 'upsertFailed' }; + } + + const sessionGeneration = central?.sessionGeneration + ?? (existing?.state === 'pending' ? existing.sessionGeneration : generateUuid()); + const legacyMetadataMatches = await catalogLegacyMetadataMatches(ref.object, request.legacyMetadata); + const sourceRevision = this._sourceRevision(existing, central, sessionGeneration, encoded.payloadHash, legacyMetadataMatches); + const snapshot = this._pendingSnapshot(sessionGeneration, sourceRevision, encoded); + + if (existing && existing.sessionGeneration !== sessionGeneration) { + const transitioned = await ref.object.transitionMetadataValuesAndCatalogSyncSnapshot( + request.legacyMetadata, + existing.sessionGeneration, + snapshot, + ); + if (!transitioned) { + continue; + } + } else { + const writeResult = await ref.object.setMetadataValuesAndCatalogSyncSnapshot(request.legacyMetadata, snapshot); + if (writeResult === 'replayed' + && matchesAcknowledgedCatalogReceipt(existing, central) + && legacyMetadataMatches) { + return { status: 'acknowledged', sourceRevision }; + } + } + + let upsertResult: AgentHostDatabaseSessionV2UpsertResult; + try { + upsertResult = await this._catalogDatabase.upsertSessionV2( + this._envelope(sessionKey, sessionGeneration, sourceRevision, encoded), + central?.sessionGeneration, + ); + } catch (error) { + this._logService.warn(`[AgentHostCatalogSync] Failed to upsert sessions_v2 row for ${sessionKey}`, error); + return { status: 'pending', sourceRevision, reason: 'upsertFailed' }; + } + if (upsertResult === 'generationMismatch') { + continue; + } + if (upsertResult !== 'applied' && upsertResult !== 'replayed') { + this._logService.warn(`[AgentHostCatalogSync] sessions_v2 payload for ${sessionKey} remains pending: ${upsertResult}`); + return { status: 'pending', sourceRevision, reason: upsertResult }; + } + + const acknowledgement: ISessionCatalogSyncAcknowledgement = { + sessionGeneration, + sourceRevision, + projectionVersion: snapshot.projectionVersion, + payloadHash: snapshot.payloadHash, + }; + if (!await ref.object.acknowledgeCatalogSyncSnapshot(acknowledgement)) { + return { status: 'pending', sourceRevision, reason: 'acknowledgementSuperseded' }; + } + return { status: 'acknowledged', sourceRevision }; + } + + const snapshot = await ref.object.getCatalogSyncSnapshot(); + return { + status: 'pending', + sourceRevision: snapshot?.sourceRevision ?? INITIAL_SOURCE_REVISION, + reason: 'generationMismatch', + }; + } + + private async _synchronizeCentralOnlyNow(session: URI, request: IAgentHostCatalogSyncRequest, validate?: () => Promise): Promise { + const sessionKey = session.toString(); + const encoded = this._encode(request.data); + let observedGeneration: string | undefined; + let hasObservedGeneration = false; + let acceptGenerationWinner = false; + let pendingRevision = INITIAL_SOURCE_REVISION; + for (let attempt = 0; attempt < MAX_GENERATION_RETRIES; attempt++) { + await validate?.(); + let central: IAgentHostDatabaseSessionV2 | undefined; + try { + central = await this._catalogDatabase.getSessionV2(sessionKey); + } catch (error) { + this._logService.warn(`[AgentHostCatalogSync] Failed to read sessions_v2 row for ${sessionKey}`, error); + return { status: 'pending', sourceRevision: pendingRevision, reason: 'upsertFailed' }; + } + if (hasObservedGeneration && central?.sessionGeneration !== observedGeneration) { + if (central && acceptGenerationWinner && this._matchesEncodedPayload(central, encoded)) { + return { status: 'acknowledged', sourceRevision: central.sourceRevision }; + } + } + observedGeneration = central?.sessionGeneration; + hasObservedGeneration = true; + acceptGenerationWinner = false; + const sessionGeneration = central?.sessionGeneration ?? generateUuid(); + const matches = central?.payloadVersion === AGENT_HOST_CATALOG_PAYLOAD_VERSION + && central.payloadHash === encoded.payloadHash; + if (central && matches) { + return { status: 'acknowledged', sourceRevision: central.sourceRevision }; + } + const sourceRevision = central ? central.sourceRevision + 1 : INITIAL_SOURCE_REVISION; + pendingRevision = sourceRevision; + let result: AgentHostDatabaseSessionV2UpsertResult; + try { + await validate?.(); + result = await this._catalogDatabase.upsertSessionV2( + this._envelope(sessionKey, sessionGeneration, sourceRevision, encoded), + central?.sessionGeneration, + ); + } catch (error) { + this._logService.warn(`[AgentHostCatalogSync] Failed to upsert sessions_v2 row for ${sessionKey}`, error); + return { status: 'pending', sourceRevision, reason: 'upsertFailed' }; + } + if (result === 'generationMismatch') { + acceptGenerationWinner = true; + continue; + } + if (result === 'conflict') { + continue; + } + if (result === 'stale') { + let winner: IAgentHostDatabaseSessionV2 | undefined; + try { + winner = await this._catalogDatabase.getSessionV2(sessionKey); + } catch (error) { + this._logService.warn(`[AgentHostCatalogSync] Failed to verify newer sessions_v2 row for ${sessionKey}`, error); + return { status: 'pending', sourceRevision, reason: 'upsertFailed' }; + } + if (winner?.sessionGeneration === sessionGeneration + && winner.sourceRevision > sourceRevision + && this._matchesEncodedPayload(winner, encoded)) { + return { status: 'acknowledged', sourceRevision: winner.sourceRevision }; + } + continue; + } + if (result === 'applied' || result === 'replayed') { + const landed = await this._catalogDatabase.getSessionV2(sessionKey); + if (landed?.sessionGeneration === sessionGeneration + && landed.sourceRevision === sourceRevision + && this._matchesEncodedPayload(landed, encoded)) { + return { status: 'acknowledged', sourceRevision }; + } + continue; + } + return { status: 'pending', sourceRevision, reason: result }; + } + return { + status: 'pending', + sourceRevision: pendingRevision, + reason: 'conflict', + }; + } + + private async _storePending( + database: ReturnType['object'], + request: IAgentHostCatalogSyncRequest, + encoded: IAgentHostCatalogEncodedPayload, + existing: ISessionCatalogSyncSnapshot | undefined, + legacyMetadataMatches: boolean, + ): Promise { + const sessionGeneration = existing?.sessionGeneration ?? generateUuid(); + const sourceRevision = this._sourceRevision(existing, undefined, sessionGeneration, encoded.payloadHash, legacyMetadataMatches); + const snapshot = this._pendingSnapshot(sessionGeneration, sourceRevision, encoded); + if (existing && existing.sessionGeneration !== sessionGeneration) { + await database.transitionMetadataValuesAndCatalogSyncSnapshot(request.legacyMetadata, existing.sessionGeneration, snapshot); + } else { + await database.setMetadataValuesAndCatalogSyncSnapshot(request.legacyMetadata, snapshot); + } + return snapshot; + } + + private _sourceRevision( + existing: ISessionCatalogSyncSnapshot | undefined, + central: IAgentHostDatabaseSessionV2Receipt | undefined, + sessionGeneration: string, + payloadHash: string, + legacyMetadataMatches: boolean, + ): number { + const local = existing?.sessionGeneration === sessionGeneration ? existing : undefined; + const current = central?.sessionGeneration === sessionGeneration ? central : undefined; + const baselineRevision = Math.max( + local?.sourceRevision ?? INITIAL_SOURCE_REVISION, + current?.sourceRevision ?? INITIAL_SOURCE_REVISION, + ); + const localMatches = local?.projectionVersion === AGENT_HOST_CATALOG_PAYLOAD_VERSION + && local.payloadHash === payloadHash; + const centralMatches = current?.payloadVersion === AGENT_HOST_CATALOG_PAYLOAD_VERSION + && current.payloadHash === payloadHash; + if (legacyMetadataMatches) { + if (localMatches && (!current || centralMatches || local.sourceRevision > current.sourceRevision)) { + return baselineRevision; + } + if (!local && centralMatches) { + return baselineRevision; + } + } + return local || current ? baselineRevision + 1 : INITIAL_SOURCE_REVISION; + } + + private _pendingSnapshot(sessionGeneration: string, sourceRevision: number, encoded: IAgentHostCatalogEncodedPayload): ISessionCatalogSyncPendingSnapshot { + return { + sessionGeneration, + sourceRevision, + projectionVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + payload: encoded.payload, + payloadHash: encoded.payloadHash, + state: 'pending', + }; + } + + private _envelope(session: string, sessionGeneration: string, sourceRevision: number, encoded: IAgentHostCatalogEncodedPayload): IAgentHostDatabaseSessionV2Envelope { + return { + session, + sessionGeneration, + sourceRevision, + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + payloadHash: encoded.payloadHash, + verified: true, + payload: encoded.payload, + }; + } + + private _encode(data: AgentHostCatalogData): IAgentHostCatalogEncodedPayload { + const result = encodeAgentHostCatalogPayload(data); + if (!result.ok) { + throw new Error(`Invalid catalog data: ${result.error}`); + } + return result.value; + } + + private _matchesEncodedPayload(receipt: IAgentHostDatabaseSessionV2, encoded: IAgentHostCatalogEncodedPayload): boolean { + return receipt.payloadVersion === AGENT_HOST_CATALOG_PAYLOAD_VERSION + && receipt.payloadHash === encoded.payloadHash + && receipt.payload === encoded.payload; + } + + private async _markPayloadDirty(session: URI): Promise { + try { + return await this._catalogDatabase.markSessionV2PayloadDirty(session.toString()); + } catch (error) { + this._logService.warn(`[AgentHostCatalogSync] Failed to mark sessions_v2 payload dirty for ${session.toString()}`, error); + return undefined; + } + } + +} diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index 01082bceb6c919..42c8182c4352d9 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -5,9 +5,11 @@ import * as fs from 'fs'; import type { Database, RunResult } from '@vscode/sqlite3'; +import { Sequencer } from '../../../base/common/async.js'; import { dirname } from '../../../base/common/path.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { AgentProvider } from '../common/agent.js'; +import { decodeAgentHostCatalogPayload, hashAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; /** * Durable origin used to resolve competing registrations for the same session. @@ -49,10 +51,70 @@ export interface IAgentHostDatabaseModifiedTimeUpdate { readonly modifiedTime: number; } +export type AgentHostSessionsV2ExclusionReason = 'backing' | 'subagent' | 'providerAbsent' | 'staleExternal'; + +export interface IAgentHostDatabaseSessionsV2Exclusion { + readonly provider: AgentProvider; + readonly session: string; + readonly reason: AgentHostSessionsV2ExclusionReason; + readonly fingerprint: string; +} + +export interface IAgentHostDatabaseSessionsV2ExclusionExpectation { + readonly identity: IAgentHostDatabaseSession | undefined; + readonly catalog: Pick | undefined; +} + +export type AgentHostDatabaseSessionV2ExclusionResult = 'excluded' | 'stale'; + +/** Durable catalog envelope written alongside the opaque, self-describing payload. */ +export interface IAgentHostDatabaseSessionV2Envelope { + readonly session: string; + readonly sessionGeneration: string; + readonly sourceRevision: number; + readonly payloadVersion: number; + readonly payloadHash: string; + readonly verified: true; + readonly payload: string; +} + +/** Envelope identity without the payload, for callers that only compare receipts. */ +export interface IAgentHostDatabaseSessionV2Receipt extends Omit, IAgentHostDatabaseSession { + /** Derived from the validated payload so the catalog can hide chat-backing rows without decoding. */ + readonly isChatBacking: boolean; + /** `0` when clean; positive values are monotonic dirty markers used for compare-and-set repair. */ + readonly payloadDirty: number; +} + +export interface IAgentHostDatabaseSessionV2 extends IAgentHostDatabaseSessionV2Receipt { + readonly payload: string; +} + +export interface IAgentHostDatabaseSessionChat { + readonly chat: string; + readonly order: number; + readonly providerData?: string; + readonly origin?: string; + readonly inheritedTurnId?: string; +} + +export interface IAgentHostDatabaseSessionChatCatalog { + readonly revision: number; + readonly legacyMirroredRevision: number; + readonly legacyMirroredPayload?: string; + readonly chats: readonly IAgentHostDatabaseSessionChat[]; +} + +export type AgentHostDatabaseSessionChatCatalogReplaceResult = + | { readonly status: 'applied'; readonly revision: number } + | { readonly status: 'conflict' | 'missingSession' | 'tombstoned' }; + +export type AgentHostDatabaseSessionV2UpsertResult = 'applied' | 'replayed' | 'stale' | 'conflict' | 'generationMismatch' | 'missingSession' | 'tombstoned'; + export interface IAgentHostDatabase extends IDisposable { /** - * Records a session with source-aware provenance. When requested, the - * tombstone check and registration are atomic. + * Records an identity in the legacy session registry for compatibility. + * When requested, the tombstone check and registration are atomic. */ registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise; unregisterSession(session: string): Promise; @@ -78,12 +140,39 @@ export interface IAgentHostDatabase extends IDisposable { isProviderBackfilled(provider: AgentProvider): Promise; /** Durably records a completed provider-native discovery pass. */ markProviderBackfilled(provider: AgentProvider): Promise; + /** Whether a provider has completed backfill for a specific v2 payload version. */ + isSessionsV2Backfilled(provider: AgentProvider, payloadVersion: number): Promise; + /** Records that a provider completed backfill for a specific v2 payload version. */ + markSessionsV2Backfilled(provider: AgentProvider, payloadVersion: number): Promise; + /** Durably records a non-deletion exclusion from the current v2 catalog. */ + markSessionsV2Excluded(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise; + /** Durably records multiple non-deletion exclusions in one transaction. */ + markSessionsV2ExcludedBatch?(exclusions: readonly IAgentHostDatabaseSessionsV2Exclusion[]): Promise; + /** Atomically excludes and removes the observed current v2 identity. */ + excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion, expected: IAgentHostDatabaseSessionsV2ExclusionExpectation): Promise; + /** Reads a session's current-v2 exclusion, when present. */ + getSessionsV2Exclusion(provider: AgentProvider, session: string): Promise; + /** Lists one provider's current-v2 exclusions without opening session databases. */ + listSessionsV2Exclusions(provider: AgentProvider): Promise; + /** Clears a current-v2 exclusion when a session becomes eligible again. */ + clearSessionsV2Exclusion(provider: AgentProvider, session: string): Promise; /** Whether `session` was explicitly deleted and must not be resurrected by backfill. */ isSessionTombstoned(session: string): Promise; /** Durably records that `session` was explicitly deleted. */ markSessionTombstoned(session: string): Promise; /** Clears a session's deletion tombstone (used on explicit create/restore). */ clearSessionTombstone(session: string): Promise; + /** + * Records a normal current-runtime identity in v2 and atomically mirrors its + * resolved identity to the legacy registry for downgrade compatibility. + */ + registerRuntimeSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise; + /** Removes a normal current-runtime identity from both registries atomically. */ + unregisterRuntimeSession(session: string): Promise; + /** Resolves normal current-runtime provenance in both registries atomically. */ + updateRuntimeSessionExternal(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise; + /** Cooling-only: union of current and legacy identity keys for runtime deduplication. */ + listRuntimeCompatibleSessionKeys(): Promise; /** * Records whether Agent Merge is enabled for `session`. This host-owned index * lets startup find the few monitored sessions without opening every session @@ -92,9 +181,80 @@ export interface IAgentHostDatabase extends IDisposable { setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise; /** Session URIs currently marked Agent-Merge-enabled. */ listAgentMergeEnabledSessions(): Promise; + /** Importer-only: records an identity in v2 without writing the legacy registry. */ + registerSessionV2(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise; + /** Importer-only: removes an identity and its payload from v2 without changing legacy. */ + unregisterSessionV2(session: string): Promise; + /** Importer-only: updates unresolved provenance in v2 without changing legacy. */ + updateSessionV2External(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise; + /** Importer-only: replaces v2 identity with newer legacy compatibility input and returns the resulting identity. */ + reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise; + /** Returns a current v2 registry identity, including one whose payload is incomplete. */ + getSessionV2Registration(session: string): Promise; + /** Lists current v2 registry identities, including rows whose payloads are incomplete. */ + listSessionV2Registrations(): Promise; + /** Importer-only: lists all v2 identities, including durably excluded rows. */ + listSessionV2RegistrationsForImport(): Promise; + /** Whether the current v2 registry contains no identities. */ + isSessionV2RegistryEmpty(): Promise; + getSessionV2(session: string): Promise; + listSessionsV2(): Promise; + /** Lists catalog receipts without materializing payloads, for startup scans. */ + listSessionsV2Receipts(): Promise; + /** Marks one cached payload dirty and returns the marker repair must compare-and-set. */ + markSessionV2PayloadDirty(session: string): Promise; + /** Reads the dirty marker even when the registered session has no verified payload yet. */ + getSessionV2PayloadDirty(session: string): Promise; + /** Marks every cached payload dirty once so mutations made by older builds are rechecked. */ + markAllSessionsV2PayloadsDirty(): Promise; + /** Clears a dirty marker only when no newer mutation superseded it. */ + markSessionV2PayloadClean(session: string, expectedDirty: number): Promise; + upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise; + /** Reads authoritative peer-chat membership. `undefined` means legacy import has not completed. */ + getSessionChatCatalog(session: string): Promise; + /** Replaces authoritative peer-chat membership when the session exists and its revision still matches. */ + replaceSessionChatCatalog(session: string, chats: readonly IAgentHostDatabaseSessionChat[], expectedRevision: number | undefined): Promise; + /** Acknowledges the exact central revision written to the downgrade-compatibility mirror. */ + markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number, payload?: string): Promise; + /** Records the legacy payload used as the next three-way merge base without acknowledging a central revision. */ + recordSessionChatCatalogLegacyMirrorPayload(session: string, expectedRevision: number, payload: string): Promise; close(): Promise; } +const sessionsV2SchemaSql = `CREATE TABLE sessions_v2 ( + session_uri TEXT PRIMARY KEY NOT NULL, + provider TEXT NOT NULL, + start_time INTEGER NOT NULL, + external INTEGER, + registration_source TEXT NOT NULL, + session_generation TEXT, + source_revision INTEGER CHECK (source_revision >= 0), + payload_version INTEGER CHECK (payload_version >= 0), + payload_hash TEXT, + verified INTEGER NOT NULL DEFAULT 0 CHECK (verified IN (0, 1)), + payload TEXT, + is_chat_backing INTEGER NOT NULL DEFAULT 0 CHECK (is_chat_backing IN (0, 1)), + modified_time INTEGER NOT NULL DEFAULT 0 +)`; + +const sessionChatCatalogSchemaSql = [ + `CREATE TABLE session_chat_catalogs ( + session_uri TEXT PRIMARY KEY NOT NULL, + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + legacy_mirrored_revision INTEGER NOT NULL DEFAULT 0 CHECK (legacy_mirrored_revision >= 0) + )`, + `CREATE TABLE session_chats ( + session_uri TEXT NOT NULL REFERENCES session_chat_catalogs(session_uri) ON DELETE CASCADE, + chat_uri TEXT NOT NULL, + chat_order INTEGER NOT NULL CHECK (chat_order >= 0), + provider_data TEXT, + origin TEXT, + inherited_turn_id TEXT, + PRIMARY KEY (session_uri, chat_uri), + UNIQUE (session_uri, chat_order) + )`, +].join(';\n'); + const migrations = [ { version: 1, @@ -128,8 +288,54 @@ const migrations = [ 'UPDATE sessions SET modified_time = start_time', ].join(';\n'), }, + { + version: 5, + sql: [ + sessionsV2SchemaSql, + `INSERT INTO sessions_v2 (session_uri, provider, start_time, external, registration_source, modified_time) + SELECT session_uri, provider, start_time, external, registration_source, modified_time FROM sessions`, + sessionChatCatalogSchemaSql, + ].join(';\n'), + }, ] as const; +const latestMigrationVersion = migrations[migrations.length - 1].version; + +async function normalizePreReleaseCatalogSchema(database: Database, currentVersion: number): Promise { + if (currentVersion < 4 || currentVersion > 11 || !await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sessions_v2'`, [])) { + return currentVersion; + } + const hasFinalCatalog = await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'session_chat_catalogs'`, []) + && await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'session_chats'`, []); + const isPreReleaseVersion11 = currentVersion === 11 && latestMigrationVersion < 11; + if (hasFinalCatalog && currentVersion >= 5 && !isPreReleaseVersion11) { + return currentVersion; + } + await exec(database, 'BEGIN TRANSACTION'); + try { + if (!hasFinalCatalog) { + const sessionColumns = await all(database, 'PRAGMA table_info(sessions)', []); + if (!sessionColumns.some(column => column.name === 'modified_time')) { + await exec(database, 'ALTER TABLE sessions ADD COLUMN modified_time INTEGER NOT NULL DEFAULT 0'); + await exec(database, 'UPDATE sessions SET modified_time = start_time'); + } + await exec(database, 'DROP TABLE sessions_v2'); + await exec(database, sessionsV2SchemaSql); + await exec(database, `INSERT INTO sessions_v2 (session_uri, provider, start_time, external, registration_source, modified_time) + SELECT session_uri, provider, start_time, external, registration_source, modified_time FROM sessions`); + await exec(database, 'DROP TABLE IF EXISTS session_chats'); + await exec(database, 'DROP TABLE IF EXISTS session_chat_catalogs'); + await exec(database, sessionChatCatalogSchemaSql); + } + await exec(database, 'PRAGMA user_version = 5'); + await exec(database, 'COMMIT'); + return 5; + } catch (error) { + await exec(database, 'ROLLBACK'); + throw error; + } +} + function openDatabase(path: string): Promise { return new Promise((resolve, reject) => { import('@vscode/sqlite3').then(sqlite3 => { @@ -176,6 +382,31 @@ function providerBackfillKey(provider: AgentProvider): string { return `sessionRegistryBackfilled:${provider}`; } +/** Metadata key for a provider's completed current-payload backfill. */ +function sessionsV2BackfillKey(provider: AgentProvider, payloadVersion: number): string { + return `sessionsV2PayloadBackfilled:${provider}:v${payloadVersion}`; +} + +const sessionsV2ExcludedKeyPrefix = 'sessionsV2Excluded:'; +const sessionsV2PayloadDirtyKeyPrefix = 'sessionsV2PayloadDirty:'; +const sessionChatCatalogLegacyMirrorKeyPrefix = 'sessionChatCatalogLegacyMirror:'; + +function sessionsV2ExcludedProviderPrefix(provider: AgentProvider): string { + return `${sessionsV2ExcludedKeyPrefix}${provider}:`; +} + +function sessionsV2ExcludedKey(provider: AgentProvider, session: string): string { + return `${sessionsV2ExcludedProviderPrefix(provider)}${session}`; +} + +function sessionsV2PayloadDirtyKey(session: string): string { + return `${sessionsV2PayloadDirtyKeyPrefix}${session}`; +} + +function sessionChatCatalogLegacyMirrorKey(session: string): string { + return `${sessionChatCatalogLegacyMirrorKeyPrefix}${session}`; +} + /** Metadata key for a session's durable "explicitly deleted" tombstone. */ function tombstoneKey(session: string): string { return `sessionTombstone:${session}`; @@ -188,10 +419,6 @@ function agentMergeEnabledKey(session: string): string { return `${agentMergeEnabledKeyPrefix}${session}`; } -function quoteSqlString(value: string): string { - return `'${value.replaceAll('\'', '\'\'')}'`; -} - function close(database: Database): Promise { return new Promise((resolve, reject) => database.close(error => error ? reject(error) : resolve())); } @@ -200,137 +427,146 @@ export class AgentHostDatabase implements IAgentHostDatabase { private _databasePromise: Promise | undefined; private _closed: Promise | true | undefined; + private readonly _transactionSequencer = new Sequencer(); constructor(private readonly _path: string) { } async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; - const changes = await runReturningChanges( - await this._ensureDatabase(), - `INSERT INTO sessions (session_uri, provider, start_time, modified_time, external, registration_source) - SELECT ?, ?, ?, ?, CASE WHEN ? = 'discovery' THEN 1 ELSE 0 END, ? - WHERE ? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true') - ON CONFLICT(session_uri) DO UPDATE SET - provider = CASE WHEN excluded.registration_source = 'explicit' THEN excluded.provider ELSE sessions.provider END, - modified_time = MAX(sessions.modified_time, excluded.modified_time), - external = CASE - WHEN excluded.registration_source = 'explicit' THEN 0 - WHEN excluded.registration_source = 'restore' THEN 0 - WHEN sessions.registration_source = 'explicit' THEN sessions.external - ELSE 1 - END, - registration_source = CASE - WHEN excluded.registration_source = 'explicit' THEN 'explicit' - WHEN sessions.registration_source = 'explicit' THEN 'explicit' - ELSE excluded.registration_source - END`, - [session, provider, startTime, modifiedTime, source, source, registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session)], - ); - if (!registerOptions.checkTombstone) { - await this.clearSessionTombstone(session); - } - return changes > 0; + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + const changes = await runReturningChanges( + database, + `INSERT INTO sessions (session_uri, provider, start_time, modified_time, external, registration_source) + SELECT ?, ?, ?, ?, CASE WHEN ? = 'discovery' THEN 1 ELSE 0 END, ? + WHERE ? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true') + ON CONFLICT(session_uri) DO UPDATE SET + provider = CASE WHEN excluded.registration_source = 'explicit' THEN excluded.provider ELSE sessions.provider END, + modified_time = MAX(sessions.modified_time, excluded.modified_time), + external = CASE + WHEN excluded.registration_source = 'explicit' THEN 0 + WHEN excluded.registration_source = 'restore' THEN 0 + WHEN sessions.registration_source = 'explicit' THEN sessions.external + ELSE 1 + END, + registration_source = CASE + WHEN excluded.registration_source = 'explicit' THEN 'explicit' + WHEN sessions.registration_source = 'explicit' THEN 'explicit' + ELSE excluded.registration_source + END`, + [session, provider, startTime, modifiedTime, source, source, registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session)], + ); + if (!registerOptions.checkTombstone) { + await run(database, 'DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]); + } + await exec(database, 'COMMIT'); + return changes > 0; + } catch (error) { + return this._rollback(database, error, `Failed to register session ${session}`); + } + }); } async unregisterSession(session: string): Promise { - const database = await this._ensureDatabase(); - try { - await exec( - database, - `BEGIN IMMEDIATE; - DELETE FROM sessions WHERE session_uri = ${quoteSqlString(session)}; - DELETE FROM metadata WHERE key = ${quoteSqlString(agentMergeEnabledKey(session))}; - COMMIT;`, - ); - } catch (error) { + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); try { - await exec(database, 'ROLLBACK'); - } catch (rollbackError) { - throw new AggregateError([error, rollbackError], `Failed to unregister session ${session}`); + await run(database, 'DELETE FROM sessions WHERE session_uri = ?', [session]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [agentMergeEnabledKey(session)]); + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, `Failed to unregister session ${session}`); } - throw error; - } + }); } async tombstoneAndUnregisterSession(session: string): Promise { - const database = await this._ensureDatabase(); - const sessionValue = quoteSqlString(session); - const tombstoneValue = quoteSqlString(tombstoneKey(session)); - try { - await exec( - database, - `BEGIN IMMEDIATE; - INSERT INTO metadata (key, value) VALUES (${tombstoneValue}, 'true') - ON CONFLICT(key) DO UPDATE SET value = excluded.value; - DELETE FROM metadata WHERE key = ${quoteSqlString(agentMergeEnabledKey(session))}; - DELETE FROM sessions WHERE session_uri = ${sessionValue}; - COMMIT;`, - ); - } catch (error) { + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); try { - await exec(database, 'ROLLBACK'); - } catch (rollbackError) { - throw new AggregateError([error, rollbackError], `Failed to tombstone session ${session}`); + await run(database, `INSERT INTO metadata (key, value) VALUES (?, 'true') + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [tombstoneKey(session)]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [agentMergeEnabledKey(session)]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionsV2PayloadDirtyKey(session)]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionChatCatalogLegacyMirrorKey(session)]); + await run(database, 'DELETE FROM sessions WHERE session_uri = ?', [session]); + await run(database, 'DELETE FROM session_chat_catalogs WHERE session_uri = ?', [session]); + await run(database, 'DELETE FROM sessions_v2 WHERE session_uri = ?', [session]); + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, `Failed to tombstone session ${session}`); } - throw error; - } + }); } async updateSessionExternal(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise { if (updates.length === 0) { return; } - const database = await this._ensureDatabase(); - const statements = updates.map(({ session, external }) => { - const externalValue = external ? 1 : 0; - const source = external - ? `'discovery'` - : `CASE WHEN registration_source = 'explicit' THEN 'explicit' ELSE 'restore' END`; - return `UPDATE sessions SET external = ${externalValue}, registration_source = ${source} WHERE session_uri = ${quoteSqlString(session)} AND external IS NULL`; - }); - try { - await exec(database, `BEGIN IMMEDIATE;\n${statements.join(';\n')};\nCOMMIT`); - } catch (error) { + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); try { - await exec(database, 'ROLLBACK'); - } catch (rollbackError) { - throw new AggregateError([error, rollbackError], 'Failed to update legacy session provenance'); + for (const { session, external } of updates) { + const source = external + ? `'discovery'` + : `CASE WHEN registration_source = 'explicit' THEN 'explicit' ELSE 'restore' END`; + await run(database, `UPDATE sessions_v2 SET external = ?, registration_source = ${source} + WHERE session_uri = ? AND external IS NULL`, [external ? 1 : 0, session]); + await run(database, `UPDATE sessions SET external = ?, registration_source = ${source} + WHERE session_uri = ? AND external IS NULL`, [external ? 1 : 0, session]); + } + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, 'Failed to update legacy session provenance'); } - throw error; - } + }); } async updateSessionModifiedTime(session: string, modifiedTime: number): Promise { - const changes = await runReturningChanges( - await this._ensureDatabase(), - 'UPDATE sessions SET modified_time = ? WHERE session_uri = ? AND modified_time < ?', - [modifiedTime, session, modifiedTime], - ); - return changes > 0; + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + const changes = await runReturningChanges( + database, + 'UPDATE sessions_v2 SET modified_time = ? WHERE session_uri = ? AND modified_time < ?', + [modifiedTime, session, modifiedTime], + ); + await run( + database, + 'UPDATE sessions SET modified_time = ? WHERE session_uri = ? AND modified_time < ?', + [modifiedTime, session, modifiedTime], + ); + await exec(database, 'COMMIT'); + return changes > 0; + } catch (error) { + return this._rollback(database, error, `Failed to update the modified time for ${session}`); + } + }); } async updateSessionModifiedTimes(updates: readonly IAgentHostDatabaseModifiedTimeUpdate[]): Promise { - // Advancing durable recency for a large catalogue one statement at a time - // dominates discovery, so the whole batch is flushed in a single - // transaction. The `modified_time < ?` guard keeps each advance monotonic - // even if a concurrent write moved a row forward since the snapshot. - const statements = updates - .filter(({ modifiedTime }) => Number.isFinite(modifiedTime)) - .map(({ session, modifiedTime }) => `UPDATE sessions SET modified_time = ${modifiedTime} WHERE session_uri = ${quoteSqlString(session)} AND modified_time < ${modifiedTime}`); - if (statements.length === 0) { + if (updates.length === 0) { return; } - const database = await this._ensureDatabase(); - try { - await exec(database, `BEGIN IMMEDIATE;\n${statements.join(';\n')};\nCOMMIT`); - } catch (error) { + await this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); try { - await exec(database, 'ROLLBACK'); - } catch (rollbackError) { - throw new AggregateError([error, rollbackError], 'Failed to advance session modified times'); + for (const { session, modifiedTime } of updates) { + await run(database, 'UPDATE sessions_v2 SET modified_time = ? WHERE session_uri = ? AND modified_time < ?', [modifiedTime, session, modifiedTime]); + await run(database, 'UPDATE sessions SET modified_time = ? WHERE session_uri = ? AND modified_time < ?', [modifiedTime, session, modifiedTime]); + } + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, 'Failed to update session modified times'); } - throw error; - } + }); } async listSessions(): Promise { @@ -391,6 +627,100 @@ export class AgentHostDatabase implements IAgentHostDatabase { ); } + async isSessionsV2Backfilled(provider: AgentProvider, payloadVersion: number): Promise { + this._validatePayloadVersion(payloadVersion); + const row = await get(await this._ensureDatabase(), 'SELECT value FROM metadata WHERE key = ?', [sessionsV2BackfillKey(provider, payloadVersion)]); + return row?.value === 'true'; + } + + markSessionsV2Backfilled(provider: AgentProvider, payloadVersion: number): Promise { + this._validatePayloadVersion(payloadVersion); + return this._run( + `INSERT INTO metadata (key, value) VALUES (?, 'true') + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [sessionsV2BackfillKey(provider, payloadVersion)], + ); + } + + markSessionsV2Excluded(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise { + return this.markSessionsV2ExcludedBatch([exclusion]); + } + + markSessionsV2ExcludedBatch(exclusions: readonly IAgentHostDatabaseSessionsV2Exclusion[]): Promise { + if (exclusions.length === 0) { + return Promise.resolve(); + } + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + for (const exclusion of exclusions) { + await run(database, `INSERT INTO metadata (key, value) + SELECT ?, ? + WHERE NOT EXISTS (SELECT 1 FROM sessions_v2 WHERE session_uri = ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [ + sessionsV2ExcludedKey(exclusion.provider, exclusion.session), + JSON.stringify({ reason: exclusion.reason, fingerprint: exclusion.fingerprint }), + exclusion.session, + ]); + } + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, 'Failed to mark sessions_v2 exclusions'); + } + }); + } + + excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion, expected: IAgentHostDatabaseSessionsV2ExclusionExpectation): Promise { + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + const observed = await get(database, `SELECT + session_uri, provider, start_time, modified_time, external, registration_source, + session_generation, source_revision, payload_hash, verified + FROM sessions_v2 WHERE session_uri = ?`, [exclusion.session]); + if (!this._matchesSessionsV2ExclusionExpectation(observed, expected)) { + await exec(database, 'COMMIT'); + return 'stale'; + } + await run(database, `INSERT INTO metadata (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [ + sessionsV2ExcludedKey(exclusion.provider, exclusion.session), + JSON.stringify({ reason: exclusion.reason, fingerprint: exclusion.fingerprint }), + ]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionsV2PayloadDirtyKey(exclusion.session)]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionChatCatalogLegacyMirrorKey(exclusion.session)]); + await run(database, 'DELETE FROM session_chat_catalogs WHERE session_uri = ?', [exclusion.session]); + await run(database, 'DELETE FROM sessions_v2 WHERE session_uri = ?', [exclusion.session]); + await exec(database, 'COMMIT'); + return 'excluded'; + } catch (error) { + return this._rollback(database, error, `Failed to exclude sessions_v2 identity ${exclusion.session}`); + } + }); + } + + async getSessionsV2Exclusion(provider: AgentProvider, session: string): Promise { + const row = await get(await this._ensureDatabase(), 'SELECT value FROM metadata WHERE key = ?', [sessionsV2ExcludedKey(provider, session)]); + return row ? this._toSessionsV2Exclusion(provider, session, row.value as string) : undefined; + } + + async listSessionsV2Exclusions(provider: AgentProvider): Promise { + const prefix = sessionsV2ExcludedProviderPrefix(provider); + const upperBound = `${prefix.slice(0, -1)};`; + const rows = await all( + await this._ensureDatabase(), + 'SELECT key, value FROM metadata WHERE key >= ? AND key < ? ORDER BY key', + [prefix, upperBound], + ); + return rows.map(row => this._toSessionsV2Exclusion(provider, (row.key as string).slice(prefix.length), row.value as string)); + } + + clearSessionsV2Exclusion(provider: AgentProvider, session: string): Promise { + return this._run('DELETE FROM metadata WHERE key = ?', [sessionsV2ExcludedKey(provider, session)]); + } + async isSessionTombstoned(session: string): Promise { const row = await get(await this._ensureDatabase(), 'SELECT value FROM metadata WHERE key = ?', [tombstoneKey(session)]); return row?.value === 'true'; @@ -408,6 +738,133 @@ export class AgentHostDatabase implements IAgentHostDatabase { return this._run('DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]); } + async registerRuntimeSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + const existing = await get(database, `SELECT provider FROM sessions_v2 WHERE session_uri = ? + UNION ALL SELECT provider FROM sessions WHERE session_uri = ? + LIMIT 1`, [session, session]); + await run(database, `INSERT INTO sessions_v2 (session_uri, provider, start_time, modified_time, external, registration_source) + SELECT session_uri, provider, start_time, modified_time, external, registration_source + FROM sessions + WHERE session_uri = ? + AND (? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true')) + AND NOT EXISTS (SELECT 1 FROM sessions_v2 WHERE session_uri = ?)`, [ + session, + registerOptions.checkTombstone ? 1 : 0, + tombstoneKey(session), + session, + ]); + const changes = await this._registerSessionV2(database, session, provider, startTime, modifiedTime, source, registerOptions); + if (changes > 0) { + const row = await get(database, 'SELECT session_uri, provider, start_time, modified_time, external, registration_source FROM sessions_v2 WHERE session_uri = ?', [session]); + if (!row) { + throw new Error(`Missing sessions_v2 identity after registering ${session}`); + } + await run(database, `INSERT INTO sessions (session_uri, provider, start_time, modified_time, external, registration_source) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(session_uri) DO UPDATE SET + provider = excluded.provider, + start_time = excluded.start_time, + modified_time = MAX(sessions.modified_time, excluded.modified_time), + external = excluded.external, + registration_source = excluded.registration_source`, [ + row.session_uri, + row.provider, + row.start_time, + row.modified_time, + row.external, + row.registration_source, + ]); + for (const excludedProvider of new Set([provider, row.provider as AgentProvider, existing?.provider as AgentProvider | undefined])) { + if (excludedProvider !== undefined) { + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionsV2ExcludedKey(excludedProvider, session)]); + } + } + } + if (!registerOptions.checkTombstone) { + await run(database, 'DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]); + } + await exec(database, 'COMMIT'); + return changes > 0; + } catch (error) { + return this._rollback(database, error, `Failed to register mirrored runtime session ${session}`); + } + }); + } + + async listRuntimeCompatibleSessionKeys(): Promise { + const rows = await all( + await this._ensureDatabase(), + `SELECT session_uri FROM sessions + WHERE NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = '${sessionsV2ExcludedKeyPrefix}' || sessions.provider || ':' || sessions.session_uri + ) + UNION + SELECT session_uri FROM sessions_v2 + WHERE NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = '${sessionsV2ExcludedKeyPrefix}' || sessions_v2.provider || ':' || sessions_v2.session_uri + ) + ORDER BY session_uri`, + [], + ); + return rows.map(row => row.session_uri as string); + } + + async unregisterRuntimeSession(session: string): Promise { + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + await run(database, 'DELETE FROM session_chat_catalogs WHERE session_uri = ?', [session]); + await run(database, 'DELETE FROM sessions_v2 WHERE session_uri = ?', [session]); + await run(database, 'DELETE FROM sessions WHERE session_uri = ?', [session]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [agentMergeEnabledKey(session)]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionsV2PayloadDirtyKey(session)]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionChatCatalogLegacyMirrorKey(session)]); + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, `Failed to unregister mirrored runtime session ${session}`); + } + }); + } + + async updateRuntimeSessionExternal(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise { + if (updates.length === 0) { + return; + } + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + for (const { session, external } of updates) { + const source = external + ? `'discovery'` + : `CASE WHEN registration_source = 'explicit' THEN 'explicit' ELSE 'restore' END`; + await run(database, `UPDATE sessions_v2 SET external = ?, registration_source = ${source} + WHERE session_uri = ? AND external IS NULL`, [external ? 1 : 0, session]); + await run(database, `INSERT INTO sessions (session_uri, provider, start_time, modified_time, external, registration_source) + SELECT session_uri, provider, start_time, modified_time, external, registration_source + FROM sessions_v2 WHERE session_uri = ? + ON CONFLICT(session_uri) DO UPDATE SET + provider = excluded.provider, + start_time = excluded.start_time, + modified_time = MAX(sessions.modified_time, excluded.modified_time), + external = excluded.external, + registration_source = excluded.registration_source`, [session]); + } + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, 'Failed to update mirrored runtime session provenance'); + } + }); + } + setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise { return enabled ? this._run( @@ -427,8 +884,655 @@ export class AgentHostDatabase implements IAgentHostDatabase { return rows.map(row => (row.key as string).slice(agentMergeEnabledKeyPrefix.length)); } + async registerSessionV2(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + const changes = await this._registerSessionV2(database, session, provider, startTime, modifiedTime, source, registerOptions); + if (!registerOptions.checkTombstone) { + await run(database, 'DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]); + } + if (changes > 0) { + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionsV2ExcludedKey(provider, session)]); + } + await exec(database, 'COMMIT'); + return changes > 0; + } catch (error) { + return this._rollback(database, error, `Failed to register sessions_v2 identity ${session}`); + } + }); + } + + async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + await run(database, `UPDATE sessions_v2 SET + provider = ?, + start_time = ?, + modified_time = MAX(modified_time, ?), + external = ?, + registration_source = ? + WHERE session_uri = ? + AND NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true') + AND NOT EXISTS (SELECT 1 FROM metadata WHERE key = ?)`, [ + legacy.provider, + legacy.startTime, + legacy.modifiedTime, + legacy.external === undefined ? null : legacy.external ? 1 : 0, + legacy.source, + session, + tombstoneKey(session), + sessionsV2ExcludedKey(legacy.provider, session), + ]); + const row = await get(database, `SELECT session_uri, provider, start_time, modified_time, external, registration_source + FROM sessions_v2 WHERE session_uri = ?`, [session]); + await exec(database, 'COMMIT'); + return row ? this._toSessionRegistration(row) : undefined; + } catch (error) { + return this._rollback(database, error, `Failed to reconcile sessions_v2 identity ${session} from legacy`); + } + }); + } + + private _matchesSessionsV2ExclusionExpectation(row: Record | undefined, expected: IAgentHostDatabaseSessionsV2ExclusionExpectation): boolean { + if (!row) { + return expected.identity === undefined && expected.catalog === undefined; + } + const identity = expected.identity; + if (!identity + || row.provider !== identity.provider + || row.start_time !== identity.startTime + || row.modified_time !== identity.modifiedTime + || (row.external === null ? undefined : row.external === 1) !== identity.external + || row.registration_source !== identity.source) { + return false; + } + const catalog = row.verified === 1 ? expected.catalog : undefined; + return expected.catalog === undefined + ? row.verified !== 1 + : catalog !== undefined + && row.session_generation === catalog.sessionGeneration + && row.source_revision === catalog.sourceRevision + && row.payload_hash === catalog.payloadHash; + } + + async unregisterSessionV2(session: string): Promise { + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + await run(database, 'DELETE FROM session_chat_catalogs WHERE session_uri = ?', [session]); + await run(database, 'DELETE FROM sessions_v2 WHERE session_uri = ?', [session]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [agentMergeEnabledKey(session)]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionsV2PayloadDirtyKey(session)]); + await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionChatCatalogLegacyMirrorKey(session)]); + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, `Failed to unregister sessions_v2 identity ${session}`); + } + }); + } + + async updateSessionV2External(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise { + if (updates.length === 0) { + return; + } + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + for (const { session, external } of updates) { + const source = external + ? `'discovery'` + : `CASE WHEN registration_source = 'explicit' THEN 'explicit' ELSE 'restore' END`; + await run(database, `UPDATE sessions_v2 SET external = ?, registration_source = ${source} + WHERE session_uri = ? AND external IS NULL`, [external ? 1 : 0, session]); + } + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, 'Failed to update sessions_v2 provenance'); + } + }); + } + + async getSessionV2Registration(session: string): Promise { + const row = await get( + await this._ensureDatabase(), + `SELECT session_uri, provider, start_time, modified_time, external, registration_source + FROM sessions_v2 + WHERE session_uri = ? + AND NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true') + AND NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = '${sessionsV2ExcludedKeyPrefix}' || sessions_v2.provider || ':' || sessions_v2.session_uri + )`, + [session, tombstoneKey(session)], + ); + return row ? this._toSessionRegistration(row) : undefined; + } + + async listSessionV2Registrations(): Promise { + const rows = await all( + await this._ensureDatabase(), + `SELECT session_uri, provider, start_time, modified_time, external, registration_source + FROM sessions_v2 + WHERE NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = 'sessionTombstone:' || sessions_v2.session_uri AND value = 'true' + ) + AND NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = '${sessionsV2ExcludedKeyPrefix}' || sessions_v2.provider || ':' || sessions_v2.session_uri + ) + ORDER BY session_uri`, + [], + ); + return rows.map(row => this._toSessionRegistration(row)); + } + + async listSessionV2RegistrationsForImport(): Promise { + const rows = await all( + await this._ensureDatabase(), + `SELECT session_uri, provider, start_time, modified_time, external, registration_source + FROM sessions_v2 + ORDER BY session_uri`, + [], + ); + return rows.map(row => this._toSessionRegistration(row)); + } + + async isSessionV2RegistryEmpty(): Promise { + const row = await get( + await this._ensureDatabase(), + `SELECT 1 AS present FROM sessions_v2 + WHERE NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = 'sessionTombstone:' || sessions_v2.session_uri AND value = 'true' + ) + AND NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = '${sessionsV2ExcludedKeyPrefix}' || sessions_v2.provider || ':' || sessions_v2.session_uri + ) + LIMIT 1`, + [], + ); + return row === undefined; + } + + async getSessionV2(session: string): Promise { + const row = await get( + await this._ensureDatabase(), + `SELECT sessions_v2.*, COALESCE(CAST(( + SELECT value FROM metadata WHERE key = '${sessionsV2PayloadDirtyKeyPrefix}' || sessions_v2.session_uri + ) AS INTEGER), 0) AS payload_dirty + FROM sessions_v2 + WHERE sessions_v2.session_uri = ? AND sessions_v2.verified = 1 + AND NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true') + AND NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = '${sessionsV2ExcludedKeyPrefix}' || sessions_v2.provider || ':' || sessions_v2.session_uri + )`, + [session, tombstoneKey(session)], + ); + return row ? { ...this._toSessionV2Receipt(row), payload: row.payload as string } : undefined; + } + + async listSessionsV2(): Promise { + const rows = await all(await this._ensureDatabase(), this._selectVerifiedSessionsV2( + `sessions_v2.*, COALESCE(CAST(( + SELECT value FROM metadata WHERE key = '${sessionsV2PayloadDirtyKeyPrefix}' || sessions_v2.session_uri + ) AS INTEGER), 0) AS payload_dirty`, + ), []); + return rows.map(row => ({ ...this._toSessionV2Receipt(row), payload: row.payload as string })); + } + + async listSessionsV2Receipts(): Promise { + const rows = await all(await this._ensureDatabase(), this._selectVerifiedSessionsV2( + `session_uri, provider, start_time, modified_time, external, registration_source, + session_generation, source_revision, payload_version, payload_hash, is_chat_backing, + COALESCE(CAST(( + SELECT value FROM metadata WHERE key = '${sessionsV2PayloadDirtyKeyPrefix}' || sessions_v2.session_uri + ) AS INTEGER), 0) AS payload_dirty`, + ), []); + return rows.map(row => this._toSessionV2Receipt(row)); + } + + async markSessionV2PayloadDirty(session: string): Promise { + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + const exists = await get(database, 'SELECT 1 AS present FROM sessions_v2 WHERE session_uri = ?', [session]); + if (exists) { + await run(database, `INSERT INTO metadata (key, value) VALUES (?, '1') + ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1`, [sessionsV2PayloadDirtyKey(session)]); + } + const row = exists + ? await get(database, 'SELECT CAST(value AS INTEGER) AS payload_dirty FROM metadata WHERE key = ?', [sessionsV2PayloadDirtyKey(session)]) + : undefined; + await exec(database, 'COMMIT'); + return row?.payload_dirty as number | undefined; + } catch (error) { + return this._rollback(database, error, `Failed to mark sessions_v2 payload dirty for ${session}`); + } + }); + } + + async getSessionV2PayloadDirty(session: string): Promise { + const row = await get(await this._ensureDatabase(), 'SELECT CAST(value AS INTEGER) AS payload_dirty FROM metadata WHERE key = ?', [sessionsV2PayloadDirtyKey(session)]); + return row?.payload_dirty as number | undefined; + } + + async markAllSessionsV2PayloadsDirty(): Promise { + return this._transactionSequencer.queue(async () => { + await run(await this._ensureDatabase(), `INSERT INTO metadata (key, value) + SELECT '${sessionsV2PayloadDirtyKeyPrefix}' || session_uri, '1' FROM sessions_v2 + WHERE verified = 1 + AND NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = 'sessionTombstone:' || sessions_v2.session_uri AND value = 'true' + ) + AND NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = '${sessionsV2ExcludedKeyPrefix}' || sessions_v2.provider || ':' || sessions_v2.session_uri + ) + ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1`, []); + }); + } + + async markSessionV2PayloadClean(session: string, expectedDirty: number): Promise { + this._validatePayloadDirty(expectedDirty); + return this._transactionSequencer.queue(async () => { + const changes = await runReturningChanges(await this._ensureDatabase(), `DELETE FROM metadata + WHERE key = ? AND CAST(value AS INTEGER) = ?`, [sessionsV2PayloadDirtyKey(session), expectedDirty]); + return changes > 0; + }); + } + + async getSessionChatCatalog(session: string): Promise { + return this._transactionSequencer.queue(async () => { + const rows = await all(await this._ensureDatabase(), `SELECT + catalog.revision, + catalog.legacy_mirrored_revision, + (SELECT value FROM metadata WHERE key = ?) AS legacy_mirrored_payload, + chat.chat_uri, + chat.chat_order, + chat.provider_data, + chat.origin, + chat.inherited_turn_id + FROM session_chat_catalogs AS catalog + LEFT JOIN session_chats AS chat ON chat.session_uri = catalog.session_uri + WHERE catalog.session_uri = ? + ORDER BY chat.chat_order`, [sessionChatCatalogLegacyMirrorKey(session), session]); + const catalog = rows[0]; + if (!catalog) { + return undefined; + } + return { + revision: catalog.revision as number, + legacyMirroredRevision: catalog.legacy_mirrored_revision as number, + ...(catalog.legacy_mirrored_payload === null ? {} : { legacyMirroredPayload: catalog.legacy_mirrored_payload as string }), + chats: rows.filter(row => row.chat_uri !== null).map(row => ({ + chat: row.chat_uri as string, + order: row.chat_order as number, + ...(row.provider_data === null ? {} : { providerData: row.provider_data as string }), + ...(row.origin === null ? {} : { origin: row.origin as string }), + ...(row.inherited_turn_id === null ? {} : { inheritedTurnId: row.inherited_turn_id as string }), + })), + }; + }); + } + + async replaceSessionChatCatalog(session: string, chats: readonly IAgentHostDatabaseSessionChat[], expectedRevision: number | undefined): Promise { + this._validateSessionChats(chats); + if (expectedRevision !== undefined && (!Number.isSafeInteger(expectedRevision) || expectedRevision <= 0)) { + throw new Error('Expected session chat catalog revision must be a positive safe integer'); + } + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + const tombstone = await get(database, `SELECT 1 AS present FROM metadata + WHERE key = ? AND value = 'true'`, [tombstoneKey(session)]); + if (tombstone) { + await exec(database, 'COMMIT'); + return { status: 'tombstoned' }; + } + const registered = await get(database, `SELECT 1 AS present FROM sessions WHERE session_uri = ? + UNION SELECT 1 AS present FROM sessions_v2 WHERE session_uri = ? + LIMIT 1`, [session, session]); + if (!registered) { + await exec(database, 'COMMIT'); + return { status: 'missingSession' }; + } + const current = await get(database, 'SELECT revision FROM session_chat_catalogs WHERE session_uri = ?', [session]); + const currentRevision = current?.revision as number | undefined; + if (currentRevision !== expectedRevision) { + await exec(database, 'COMMIT'); + return { status: 'conflict' }; + } + const revision = (currentRevision ?? 0) + 1; + if (!Number.isSafeInteger(revision)) { + throw new Error(`Session chat catalog revision overflow for ${session}`); + } + await run(database, `INSERT INTO session_chat_catalogs (session_uri, revision) + VALUES (?, ?) + ON CONFLICT(session_uri) DO UPDATE SET revision = excluded.revision`, [session, revision]); + await run(database, 'DELETE FROM session_chats WHERE session_uri = ?', [session]); + for (const chat of chats) { + await run(database, `INSERT INTO session_chats ( + session_uri, chat_uri, chat_order, provider_data, origin, inherited_turn_id + ) VALUES (?, ?, ?, ?, ?, ?)`, [ + session, + chat.chat, + chat.order, + chat.providerData ?? null, + chat.origin ?? null, + chat.inheritedTurnId ?? null, + ]); + } + await exec(database, 'COMMIT'); + return { status: 'applied', revision }; + } catch (error) { + return this._rollback(database, error, `Failed to replace the chat catalog for ${session}`); + } + }); + } + + async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number, payload?: string): Promise { + if (!Number.isSafeInteger(expectedRevision) || expectedRevision <= 0) { + throw new Error('Session chat catalog revision must be a positive safe integer'); + } + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + await run(database, `UPDATE session_chat_catalogs SET legacy_mirrored_revision = ? + WHERE session_uri = ? AND revision = ? AND legacy_mirrored_revision < ?`, [ + expectedRevision, + session, + expectedRevision, + expectedRevision, + ]); + const row = await get(database, `SELECT revision, legacy_mirrored_revision + FROM session_chat_catalogs WHERE session_uri = ?`, [session]); + const mirrored = row?.revision === expectedRevision && row.legacy_mirrored_revision === expectedRevision; + if (row && payload !== undefined) { + await run(database, `INSERT INTO metadata (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [sessionChatCatalogLegacyMirrorKey(session), payload]); + } + await exec(database, 'COMMIT'); + return mirrored; + } catch (error) { + return this._rollback(database, error, `Failed to mark the chat catalog mirrored for ${session}`); + } + }); + } + + async recordSessionChatCatalogLegacyMirrorPayload(session: string, expectedRevision: number, payload: string): Promise { + if (!Number.isSafeInteger(expectedRevision) || expectedRevision <= 0) { + throw new Error('Session chat catalog revision must be a positive safe integer'); + } + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + const row = await get(database, 'SELECT revision FROM session_chat_catalogs WHERE session_uri = ?', [session]); + if (row?.revision !== expectedRevision) { + await exec(database, 'COMMIT'); + return false; + } + await run(database, `INSERT INTO metadata (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [sessionChatCatalogLegacyMirrorKey(session), payload]); + await exec(database, 'COMMIT'); + return true; + } catch (error) { + return this._rollback(database, error, `Failed to record the chat catalog mirror base for ${session}`); + } + }); + } + + async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { + const isChatBacking = this._validateSessionV2Envelope(envelope); + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + const tombstone = await get(database, 'SELECT value FROM metadata WHERE key = ?', [tombstoneKey(envelope.session)]); + if (tombstone?.value === 'true') { + await exec(database, 'COMMIT'); + return 'tombstoned'; + } + const registry = await get(database, 'SELECT provider, start_time, modified_time, external, registration_source FROM sessions_v2 WHERE session_uri = ?', [envelope.session]); + if (!registry) { + await exec(database, 'COMMIT'); + return 'missingSession'; + } + const exclusion = await get(database, 'SELECT 1 FROM metadata WHERE key = ?', [sessionsV2ExcludedKey(registry.provider as AgentProvider, envelope.session)]); + if (exclusion) { + await exec(database, 'COMMIT'); + return 'missingSession'; + } + const current = await get(database, 'SELECT session_generation, source_revision, payload_version, payload_hash, verified FROM sessions_v2 WHERE session_uri = ?', [envelope.session]); + const currentGeneration = current?.session_generation === null || current?.verified !== 1 ? undefined : current?.session_generation as string; + if (currentGeneration !== expectedSessionGeneration) { + await exec(database, 'COMMIT'); + return 'generationMismatch'; + } + if (currentGeneration === envelope.sessionGeneration) { + const currentRevision = current?.source_revision as number; + if (envelope.sourceRevision < currentRevision) { + await exec(database, 'COMMIT'); + return 'stale'; + } + if (envelope.sourceRevision === currentRevision) { + const replayed = current?.payload_version === envelope.payloadVersion && current?.payload_hash === envelope.payloadHash; + await exec(database, 'COMMIT'); + return replayed ? 'replayed' : 'conflict'; + } + } + + await run(database, `INSERT INTO sessions_v2 ( + session_uri, provider, start_time, modified_time, external, registration_source, + session_generation, source_revision, payload_version, payload_hash, verified, payload, is_chat_backing + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + ON CONFLICT(session_uri) DO UPDATE SET + provider = excluded.provider, + start_time = excluded.start_time, + modified_time = excluded.modified_time, + external = excluded.external, + registration_source = excluded.registration_source, + session_generation = excluded.session_generation, + source_revision = excluded.source_revision, + payload_version = excluded.payload_version, + payload_hash = excluded.payload_hash, + verified = excluded.verified, + payload = excluded.payload, + is_chat_backing = excluded.is_chat_backing`, [ + envelope.session, + registry.provider, + registry.start_time, + registry.modified_time, + registry.external, + registry.registration_source, + envelope.sessionGeneration, + envelope.sourceRevision, + envelope.payloadVersion, + envelope.payloadHash, + envelope.payload, + isChatBacking ? 1 : 0, + ]); + await exec(database, 'COMMIT'); + return 'applied'; + } catch (error) { + return this._rollback(database, error, `Failed to upsert sessions_v2 row for ${envelope.session}`); + } + }); + } + + private _registerSessionV2( + database: Database, + session: string, + provider: AgentProvider, + startTime: number, + modifiedTime: number, + source: AgentSessionRegistrationSource, + registerOptions: IAgentHostDatabaseRegisterOptions, + ): Promise { + return runReturningChanges( + database, + `INSERT INTO sessions_v2 (session_uri, provider, start_time, modified_time, external, registration_source) + SELECT ?, ?, ?, ?, CASE WHEN ? = 'discovery' THEN 1 ELSE 0 END, ? + WHERE ? = 0 OR NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true') + ON CONFLICT(session_uri) DO UPDATE SET + provider = CASE WHEN excluded.registration_source = 'explicit' THEN excluded.provider ELSE sessions_v2.provider END, + modified_time = MAX(sessions_v2.modified_time, excluded.modified_time), + external = CASE + WHEN excluded.registration_source IN ('explicit', 'restore') THEN 0 + WHEN sessions_v2.registration_source = 'explicit' THEN sessions_v2.external + ELSE 1 + END, + registration_source = CASE + WHEN excluded.registration_source = 'explicit' THEN 'explicit' + WHEN sessions_v2.registration_source = 'explicit' THEN 'explicit' + ELSE excluded.registration_source + END`, + [session, provider, startTime, modifiedTime, source, source, registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session)], + ); + } + + /** + * Validates the envelope against its opaque payload and returns the derived + * chat-backing flag, so the payload stays the only authority for content. + */ + private _validateSessionV2Envelope(envelope: IAgentHostDatabaseSessionV2Envelope): boolean { + for (const [name, value] of [ + ['sourceRevision', envelope.sourceRevision], + ['payloadVersion', envelope.payloadVersion], + ] as const) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Catalog ${name} must be a non-negative safe integer`); + } + } + for (const [name, value] of [ + ['session', envelope.session], + ['sessionGeneration', envelope.sessionGeneration], + ['payloadHash', envelope.payloadHash], + ['payload', envelope.payload], + ] as const) { + if (!value) { + throw new Error(`Catalog ${name} must not be empty`); + } + } + if (envelope.verified !== true) { + throw new Error('Catalog envelope must be verified before it is stored'); + } + const decoded = decodeAgentHostCatalogPayload(envelope.payload); + if (!decoded.ok) { + throw new Error(`Catalog payload is ${decoded.reason}: ${decoded.error}`); + } + if (decoded.value.payload !== envelope.payload) { + throw new Error('Catalog payload must be canonical JSON'); + } + if (hashAgentHostCatalogPayload(envelope.payload) !== envelope.payloadHash) { + throw new Error('Catalog payloadHash must match payload'); + } + return decoded.value.data.isChatBacking === true; + } + + private _validatePayloadVersion(payloadVersion: number): void { + if (!Number.isSafeInteger(payloadVersion) || payloadVersion < 0) { + throw new Error('Catalog payloadVersion must be a non-negative safe integer'); + } + } + + private _validateSessionChats(chats: readonly IAgentHostDatabaseSessionChat[]): void { + const uris = new Set(); + for (let index = 0; index < chats.length; index++) { + const chat = chats[index]; + if (!chat.chat) { + throw new Error('Session chat URI must not be empty'); + } + if (chat.order !== index) { + throw new Error('Session chat order must be contiguous and zero-based'); + } + if (uris.has(chat.chat)) { + throw new Error(`Session chat URI must be unique: ${chat.chat}`); + } + uris.add(chat.chat); + } + } + + private _selectVerifiedSessionsV2(columns: string): string { + return `SELECT ${columns} + FROM sessions_v2 + WHERE sessions_v2.verified = 1 + AND NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = 'sessionTombstone:' || sessions_v2.session_uri AND value = 'true' + ) + AND NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = '${sessionsV2ExcludedKeyPrefix}' || sessions_v2.provider || ':' || sessions_v2.session_uri + ) + ORDER BY sessions_v2.session_uri`; + } + + private _toSessionsV2Exclusion(provider: AgentProvider, session: string, value: string): IAgentHostDatabaseSessionsV2Exclusion { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== 'object' + || !['backing', 'subagent', 'providerAbsent', 'staleExternal'].includes(parsed.reason) + || typeof parsed.fingerprint !== 'string') { + throw new Error(`Invalid sessions_v2 exclusion for ${session}`); + } + return { provider, session, reason: parsed.reason, fingerprint: parsed.fingerprint }; + } + + private _toSessionV2Receipt(row: Record): IAgentHostDatabaseSessionV2Receipt { + return { + ...this._toSessionRegistration(row), + sessionGeneration: row.session_generation as string, + sourceRevision: row.source_revision as number, + payloadVersion: row.payload_version as number, + payloadHash: row.payload_hash as string, + verified: true, + isChatBacking: row.is_chat_backing === 1, + payloadDirty: row.payload_dirty as number, + }; + } + + private _validatePayloadDirty(payloadDirty: number): void { + if (!Number.isSafeInteger(payloadDirty) || payloadDirty <= 0) { + throw new Error('Catalog payload dirty marker must be a positive safe integer'); + } + } + + private _toSessionRegistration(row: Record): IAgentHostDatabaseSession { + return { + session: row.session_uri as string, + provider: row.provider as AgentProvider, + startTime: row.start_time as number, + modifiedTime: row.modified_time as number, + external: row.external === null ? undefined : row.external === 1, + source: row.registration_source as AgentSessionRegistrationSource, + }; + } + + private async _rollback(database: Database, error: unknown, message: string): Promise { + try { + await exec(database, 'ROLLBACK'); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], message); + } + throw error; + } + private async _run(sql: string, parameters: readonly unknown[]): Promise { - await run(await this._ensureDatabase(), sql, parameters); + await this._transactionSequencer.queue(async () => run(await this._ensureDatabase(), sql, parameters)); } private _ensureDatabase(): Promise { @@ -443,8 +1547,9 @@ export class AgentHostDatabase implements IAgentHostDatabase { const database = await openDatabase(this._path); try { database.serialize(); + await exec(database, 'PRAGMA foreign_keys = ON'); const versionRow = await get(database, 'PRAGMA user_version', []); - const currentVersion = (versionRow?.user_version as number | undefined) ?? 0; + const currentVersion = await normalizePreReleaseCatalogSchema(database, (versionRow?.user_version as number | undefined) ?? 0); for (const migration of migrations) { if (migration.version > currentVersion) { await exec(database, 'BEGIN TRANSACTION'); diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts new file mode 100644 index 00000000000000..8ab428a7b19061 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -0,0 +1,617 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toErrorMessage } from '../../../base/common/errorMessage.js'; +import { Limiter } from '../../../base/common/async.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import type { AgentHostCatalogDatabaseReference } from './agentHostCatalogSyncService.js'; +import { ChatOrigin } from '../common/state/protocol/state.js'; +import { isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; +import { fromCatalogChatOrigin, toSerializableJsonValue } from './agentHostCatalogSourceResolver.js'; +import { AGENT_HOST_CATALOG_CHILD_LIMIT } from './agentHostCatalogProjection.js'; +import { IAgentHostDatabase } from './agentHostDatabase.js'; + +export const PEER_CHATS_METADATA_KEY = 'peerChats'; +export const CHAT_PROVIDER_DATA_METADATA_KEY = 'agentHost.chatProviderData'; +export const CHAT_ORIGIN_METADATA_KEY = 'agentHost.chatOrigin'; +export const CHAT_INHERITED_TURN_METADATA_KEY = 'agentHost.chatInheritedTurnId'; +const CHAT_METADATA_CONCURRENCY = 4; +const IMPORTED_PEER_CHAT_LIMIT = AGENT_HOST_CATALOG_CHILD_LIMIT - 1; + +export interface IPersistedPeerChat { + readonly uri: string; + readonly providerData?: string; + readonly origin?: ChatOrigin; + readonly inheritedTurnId?: string; +} + +export class AgentHostPeerChatStore { + + private readonly _writes = new Map>(); + private readonly _deletingSessions = new Map(); + + constructor( + private readonly _database: IAgentHostDatabase, + private readonly _sessionDataService: ISessionDataService, + private readonly _logService: ILogService, + ) { } + + async tryRead(session: URI, repairLegacyMirror = true): Promise { + return this._readCentral(session, repairLegacyMirror); + } + + /** Imports membership changed by an older build, then returns central authority. */ + async reconcileLegacy(session: URI, database?: AgentHostCatalogDatabaseReference): Promise { + let result: IPersistedPeerChat[] | undefined; + await this._enqueue(session, async () => { + while (true) { + const catalog = await this._database.getSessionChatCatalog(session.toString()); + const legacyState = await this._tryReadLegacyPayload(session, false, database); + const legacy = legacyState?.entries; + if (!catalog) { + if (legacy === undefined) { + return; + } + const replaceResult = await this._replaceCentral(session, legacy, undefined, true, database); + if (replaceResult === 'conflict') { + continue; + } + if (replaceResult === 'sessionUnavailable') { + return; + } + result = legacy; + return; + } + const central = this._entriesFromCatalog(catalog.chats); + if (catalog.legacyMirroredRevision !== catalog.revision) { + const reconciled = await this._reconcileUnmirroredCatalog(session, database); + result = reconciled.status === 'available' ? reconciled.entries : undefined; + return; + } + if (legacy === undefined) { + try { + await this._publishCompatibilityState(session, central, catalog.revision, database); + } catch (error) { + this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); + } + } + if (legacy !== undefined && catalog.legacyMirroredPayload === undefined) { + if (!await this._database.markSessionChatCatalogLegacyMirrored(session.toString(), catalog.revision, JSON.stringify(legacy))) { + continue; + } + } + if (legacy !== undefined && legacyState?.raw !== catalog.legacyMirroredPayload) { + const base = this._parseLegacyMirrorBase(session, catalog.legacyMirroredPayload); + const merged = base === undefined ? legacy : this._mergeLegacyChanges(base, central, legacy); + const replaceResult = await this._replaceCentral(session, merged, catalog.revision, true, database); + if (replaceResult === 'conflict') { + continue; + } + if (replaceResult === 'sessionUnavailable') { + return; + } + result = merged; + return; + } + const local = await this.readLocalChatMetadata(central); + if (JSON.stringify(local) !== JSON.stringify(central)) { + const replaceResult = await this._replaceCentral(session, local, catalog.revision, true, database); + if (replaceResult === 'conflict') { + continue; + } + if (replaceResult === 'sessionUnavailable') { + return; + } + } + result = local; + return; + } + }); + return result; + } + + private async _readCentral(session: URI, repairLegacyMirror: boolean): Promise { + const catalog = await this._database.getSessionChatCatalog(session.toString()); + if (!catalog) { + return undefined; + } + if (repairLegacyMirror && catalog.legacyMirroredRevision !== catalog.revision) { + void this._enqueueLegacyMirror(session).catch(error => { + this._logService.error(error, `[AgentHostPeerChatStore] Failed to repair legacy peer-chat membership for ${session.toString()}`); + }); + } + return this._entriesFromCatalog(catalog.chats); + } + + /** + * Compatibility-only read used to import membership written by older builds. + * Missing or malformed data returns `undefined`; `[]` is an explicit empty sentinel. + */ + async tryReadLegacy(session: URI, batched = false, database?: AgentHostCatalogDatabaseReference): Promise { + return (await this._tryReadLegacyPayload(session, batched, database))?.entries; + } + + private async _tryReadLegacyPayload(session: URI, batched = false, database?: AgentHostCatalogDatabaseReference): Promise<{ readonly raw: string; readonly entries: IPersistedPeerChat[] } | undefined> { + const ref = database ?? await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return undefined; + } + try { + const raw = batched + ? (await ref.object.getMetadataObject({ [PEER_CHATS_METADATA_KEY]: true }))[PEER_CHATS_METADATA_KEY] + : await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); + if (raw === undefined) { + return undefined; + } + return { raw, entries: this._parse(session, raw, IMPORTED_PEER_CHAT_LIMIT) }; + } catch (error) { + this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(error)}`); + return undefined; + } finally { + if (!database) { + ref.dispose(); + } + } + } + + async find(session: URI, chat: URI): Promise { + const entries = await this.tryRead(session); + return entries?.find(entry => entry.uri === chat.toString()); + } + + replace(session: URI, entries: readonly IPersistedPeerChat[]): Promise { + return this._enqueueWrite(session, () => [...entries]); + } + + replaceForMigration(session: URI, entries: readonly IPersistedPeerChat[]): Promise { + return this._enqueue(session, async () => { + const sessionKey = session.toString(); + if (await this._database.getSessionChatCatalog(sessionKey)) { + return; + } + const result = await this._database.replaceSessionChatCatalog(sessionKey, this._catalogRows(entries), undefined); + if (result.status === 'applied') { + await this._database.recordSessionChatCatalogLegacyMirrorPayload(sessionKey, result.revision, JSON.stringify(entries)); + } + }); + } + + upsert(session: URI, chat: URI, providerData: string | undefined, origin?: ChatOrigin, inheritedTurnId?: string): Promise { + const chatUri = chat.toString(); + return this._enqueueWrite(session, entries => { + const existing = entries.find(entry => entry.uri === chatUri); + const effectiveOrigin = origin ?? existing?.origin; + const effectiveInheritedTurnId = inheritedTurnId ?? existing?.inheritedTurnId; + const next = entries.filter(entry => entry.uri !== chatUri); + next.push({ + uri: chatUri, + ...(providerData !== undefined ? { providerData } : {}), + ...(effectiveOrigin !== undefined ? { origin: effectiveOrigin } : {}), + ...(effectiveInheritedTurnId !== undefined ? { inheritedTurnId: effectiveInheritedTurnId } : {}), + }); + return next; + }); + } + + remove(session: URI, chat: URI): Promise { + const chatUri = chat.toString(); + return this._enqueueWrite(session, entries => entries.filter(entry => entry.uri !== chatUri)); + } + + async beginSessionDeletion(session: URI): Promise { + const key = session.toString(); + this._deletingSessions.set(key, (this._deletingSessions.get(key) ?? 0) + 1); + await this._writes.get(key)?.catch(() => { }); + } + + endSessionDeletion(session: URI): void { + const key = session.toString(); + const count = this._deletingSessions.get(key); + if (count === undefined || count <= 1) { + this._deletingSessions.delete(key); + } else { + this._deletingSessions.set(key, count - 1); + } + } + + async readLocalChatMetadata(entries: readonly IPersistedPeerChat[]): Promise { + const limiter = new Limiter(CHAT_METADATA_CONCURRENCY); + return Promise.all(entries.map(entry => limiter.queue(async () => { + try { + return await this._readChatMetadata(entry); + } catch (error) { + this._logService.warn(`[AgentHostPeerChatStore] Failed to read chat-local metadata for ${entry.uri}: ${toErrorMessage(error)}`); + return entry; + } + }))); + } + + private _enqueueWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { + return this._enqueue(session, () => this._applyWrite(session, mutate)); + } + + private _enqueue(session: URI, operation: () => Promise): Promise { + const key = session.toString(); + if (this._deletingSessions.has(key)) { + return Promise.resolve(); + } + const previous = this._writes.get(key) ?? Promise.resolve(); + const next = previous + .catch(() => { /* a failed prior write must not block later ones */ }) + .then(operation); + const clear = () => { + if (this._writes.get(key) === tracked) { + this._writes.delete(key); + } + }; + const tracked = next.then(clear, error => { + clear(); + throw error; + }); + this._writes.set(key, tracked); + return tracked; + } + + private async _applyWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { + while (true) { + let catalog = await this._database.getSessionChatCatalog(session.toString()); + let reconciledEntries: IPersistedPeerChat[] | undefined; + if (catalog && catalog.legacyMirroredRevision !== catalog.revision) { + const reconciled = await this._reconcileUnmirroredCatalog(session); + if (reconciled.status === 'sessionUnavailable') { + return; + } + if (reconciled.status === 'missingCatalog') { + continue; + } + catalog = await this._database.getSessionChatCatalog(session.toString()); + if (!catalog || catalog.revision !== reconciled.revision) { + continue; + } + reconciledEntries = reconciled.entries; + } + const central = catalog ? this._entriesFromCatalog(catalog.chats) : undefined; + const legacyState = reconciledEntries ? undefined : await this._tryReadLegacyPayload(session); + const legacy = legacyState?.entries; + if (catalog && legacy !== undefined && catalog.legacyMirroredRevision === catalog.revision && catalog.legacyMirroredPayload === undefined) { + if (!await this._database.markSessionChatCatalogLegacyMirrored(session.toString(), catalog.revision, JSON.stringify(legacy))) { + continue; + } + } + const legacyIsCurrentMirror = catalog?.legacyMirroredPayload !== undefined && legacyState?.raw === catalog.legacyMirroredPayload; + const base = catalog && legacy !== undefined && !legacyIsCurrentMirror + ? this._parseLegacyMirrorBase(session, catalog.legacyMirroredPayload) + : undefined; + const current = reconciledEntries + ?? (base && central && legacy ? this._mergeLegacyChanges(base, central, legacy) : undefined) + ?? (legacyIsCurrentMirror ? central : legacy) + ?? central + ?? []; + const updated = this._parse(session, JSON.stringify(mutate(current))); + const result = await this._replaceCentral(session, updated, catalog?.revision); + if (result !== 'conflict') { + return; + } + } + } + + private async _replaceCentral(session: URI, updated: readonly IPersistedPeerChat[], expectedRevision: number | undefined, publishCompatibility = true, database?: AgentHostCatalogDatabaseReference): Promise<'applied' | 'conflict' | 'sessionUnavailable'> { + const result = await this._database.replaceSessionChatCatalog(session.toString(), this._catalogRows(updated), expectedRevision); + if (result.status !== 'applied') { + if (result.status !== 'conflict') { + this._logService.trace(`[AgentHostPeerChatStore] Ignoring chat catalog write for unavailable session ${session.toString()}: ${result.status}`); + } + return result.status === 'conflict' ? 'conflict' : 'sessionUnavailable'; + } + if (publishCompatibility) { + try { + await this._publishCompatibilityState(session, updated, result.revision, database); + } catch (error) { + this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); + } + } + return 'applied'; + } + + private _catalogRows(entries: readonly IPersistedPeerChat[]): Array<{ + readonly chat: string; + readonly order: number; + readonly providerData?: string; + readonly origin?: string; + readonly inheritedTurnId?: string; + }> { + return entries.map((entry, order) => ({ + chat: entry.uri, + order, + ...(entry.providerData !== undefined ? { providerData: entry.providerData } : {}), + ...(entry.origin !== undefined ? { origin: this._stringifyOrigin(entry.origin) } : {}), + ...(entry.inheritedTurnId !== undefined ? { inheritedTurnId: entry.inheritedTurnId } : {}), + })); + } + + private async _publishCompatibilityState(session: URI, initialEntries: readonly IPersistedPeerChat[], initialRevision: number, database?: AgentHostCatalogDatabaseReference): Promise { + let entries = initialEntries; + let revision = initialRevision; + while (true) { + const limiter = new Limiter(CHAT_METADATA_CONCURRENCY); + await Promise.all(entries.map(entry => limiter.queue(() => this._writeChatMetadata(entry)))); + const current = await this._database.getSessionChatCatalog(session.toString()); + if (!current) { + return; + } + if (current.revision !== revision) { + entries = this._entriesFromCatalog(current.chats); + revision = current.revision; + continue; + } + if (await this._writeLegacyMirror(session, entries, revision, database)) { + return; + } + const superseding = await this._database.getSessionChatCatalog(session.toString()); + if (!superseding) { + return; + } + entries = this._entriesFromCatalog(superseding.chats); + revision = superseding.revision; + } + } + + private _enqueueLegacyMirror(session: URI): Promise { + return this._enqueue(session, async () => { + await this._reconcileUnmirroredCatalog(session); + }); + } + + private async _reconcileUnmirroredCatalog(session: URI, database?: AgentHostCatalogDatabaseReference): Promise< + | { readonly status: 'available'; readonly entries: IPersistedPeerChat[]; readonly revision: number } + | { readonly status: 'missingCatalog' } + | { readonly status: 'sessionUnavailable' } + > { + while (true) { + const catalog = await this._database.getSessionChatCatalog(session.toString()); + if (!catalog) { + return { status: 'missingCatalog' }; + } + const central = this._entriesFromCatalog(catalog.chats); + if (catalog.legacyMirroredRevision === catalog.revision) { + return { status: 'available', entries: central, revision: catalog.revision }; + } + const legacyPayload = database ? await this._tryReadLegacyPayload(session, true, database) : undefined; + const legacyState = database + ? { databaseExists: true, ...legacyPayload, entries: legacyPayload?.entries } + : await this._tryReadLegacyState(session); + if (!legacyState.databaseExists) { + return { status: 'available', entries: central, revision: catalog.revision }; + } + const legacy = legacyState.entries; + const base = this._parseLegacyMirrorBase(session, catalog.legacyMirroredPayload); + if (legacy !== undefined && base !== undefined && legacyState.raw !== catalog.legacyMirroredPayload && JSON.stringify(legacy) !== JSON.stringify(base)) { + const merged = this._mergeLegacyChanges(base, central, legacy); + const replaceResult = await this._replaceCentral(session, merged, catalog.revision, false); + if (replaceResult === 'conflict') { + continue; + } + if (replaceResult === 'sessionUnavailable') { + return { status: 'sessionUnavailable' }; + } + const revision = catalog.revision + 1; + if (!await this._database.recordSessionChatCatalogLegacyMirrorPayload(session.toString(), revision, JSON.stringify(legacy))) { + continue; + } + try { + await this._publishCompatibilityState(session, merged, revision, database); + } catch (error) { + this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); + } + return { status: 'available', entries: merged, revision }; + } + try { + await this._publishCompatibilityState(session, central, catalog.revision, database); + } catch (error) { + this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); + } + return { status: 'available', entries: central, revision: catalog.revision }; + } + } + + private async _writeLegacyMirror(session: URI, entries: readonly IPersistedPeerChat[], revision: number, database?: AgentHostCatalogDatabaseReference): Promise { + const payload = JSON.stringify(entries); + const ref = database ?? this._sessionDataService.openDatabase(session); + try { + await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, payload); + } finally { + if (!database) { + ref.dispose(); + } + } + return this._database.markSessionChatCatalogLegacyMirrored(session.toString(), revision, payload); + } + + private async _tryReadLegacyState(session: URI): Promise<{ readonly databaseExists: boolean; readonly raw?: string; readonly entries: IPersistedPeerChat[] | undefined }> { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return { databaseExists: false, entries: undefined }; + } + try { + const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); + return { + databaseExists: true, + ...(raw === undefined ? {} : { raw }), + entries: raw === undefined ? undefined : this._parse(session, raw, IMPORTED_PEER_CHAT_LIMIT), + }; + } catch (error) { + this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(error)}`); + return { databaseExists: true, entries: undefined }; + } finally { + ref.dispose(); + } + } + + private _parseLegacyMirrorBase(session: URI, payload: string | undefined): IPersistedPeerChat[] | undefined { + if (payload === undefined) { + return undefined; + } + try { + return this._parse(session, payload); + } catch (error) { + this._logService.warn(`[AgentHostPeerChatStore] Ignoring malformed legacy mirror base for ${session.toString()}: ${toErrorMessage(error)}`); + return undefined; + } + } + + private _mergeLegacyChanges(base: readonly IPersistedPeerChat[], central: readonly IPersistedPeerChat[], legacy: readonly IPersistedPeerChat[]): IPersistedPeerChat[] { + const baseByUri = new Map(base.map(entry => [entry.uri, entry])); + const centralByUri = new Map(central.map(entry => [entry.uri, entry])); + const legacyUris = new Set(legacy.map(entry => entry.uri)); + const merged: IPersistedPeerChat[] = []; + for (const legacyEntry of legacy) { + const baseEntry = baseByUri.get(legacyEntry.uri); + const centralEntry = centralByUri.get(legacyEntry.uri); + if (!baseEntry || JSON.stringify(legacyEntry) !== JSON.stringify(baseEntry)) { + merged.push(legacyEntry); + } else if (centralEntry) { + merged.push(centralEntry); + } + } + for (const centralEntry of central) { + if (!baseByUri.has(centralEntry.uri) && !legacyUris.has(centralEntry.uri)) { + merged.push(centralEntry); + } + } + return merged; + } + + private async _readChatMetadata(entry: IPersistedPeerChat): Promise { + const ref = await this._sessionDataService.tryOpenDatabase(URI.parse(entry.uri)); + if (!ref) { + return entry; + } + try { + const metadata = await ref.object.getMetadataObject({ + [CHAT_PROVIDER_DATA_METADATA_KEY]: true, + [CHAT_ORIGIN_METADATA_KEY]: true, + [CHAT_INHERITED_TURN_METADATA_KEY]: true, + }); + const origin = metadata[CHAT_ORIGIN_METADATA_KEY] + ? this._parseOrigin(metadata[CHAT_ORIGIN_METADATA_KEY]) + : metadata[CHAT_ORIGIN_METADATA_KEY] === '' ? undefined : entry.origin; + return { + uri: entry.uri, + ...(metadata[CHAT_PROVIDER_DATA_METADATA_KEY] !== undefined + ? metadata[CHAT_PROVIDER_DATA_METADATA_KEY] ? { providerData: metadata[CHAT_PROVIDER_DATA_METADATA_KEY] } : {} + : entry.providerData !== undefined ? { providerData: entry.providerData } : {}), + ...(origin !== undefined ? { origin } : {}), + ...(metadata[CHAT_INHERITED_TURN_METADATA_KEY] !== undefined + ? metadata[CHAT_INHERITED_TURN_METADATA_KEY] ? { inheritedTurnId: metadata[CHAT_INHERITED_TURN_METADATA_KEY] } : {} + : entry.inheritedTurnId !== undefined ? { inheritedTurnId: entry.inheritedTurnId } : {}), + }; + } finally { + ref.dispose(); + } + } + + private async _writeChatMetadata(entry: IPersistedPeerChat): Promise { + const ref = this._sessionDataService.openDatabase(URI.parse(entry.uri)); + try { + await ref.object.setMetadataValues({ + [CHAT_PROVIDER_DATA_METADATA_KEY]: entry.providerData ?? '', + [CHAT_ORIGIN_METADATA_KEY]: entry.origin === undefined ? '' : this._stringifyOrigin(entry.origin), + [CHAT_INHERITED_TURN_METADATA_KEY]: entry.inheritedTurnId ?? '', + }); + } finally { + ref.dispose(); + } + } + + private _parseOrigin(raw: string): ChatOrigin | undefined { + const parsed: unknown = JSON.parse(raw); + return fromCatalogChatOrigin(toSerializableJsonValue(parsed)); + } + + private _stringifyOrigin(origin: ChatOrigin): string { + const value = toSerializableJsonValue(origin); + if (value === undefined) { + throw new Error('Chat origin is not JSON-serializable'); + } + return JSON.stringify(value); + } + + private _entriesFromCatalog(chats: readonly { + readonly chat: string; + readonly providerData?: string; + readonly origin?: string; + readonly inheritedTurnId?: string; + }[]): IPersistedPeerChat[] { + return chats.map(chat => ({ + uri: chat.chat, + ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}), + ...(chat.origin !== undefined ? { origin: this._parseOrigin(chat.origin) } : {}), + ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), + })); + } + + private _parse(session: URI, raw: string, maximumEntries?: number): IPersistedPeerChat[] { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error('expected an array'); + } + if (maximumEntries !== undefined && parsed.length > maximumEntries) { + throw new Error(`legacy peer-chat catalog exceeds the ${maximumEntries} entry limit`); + } + const entryCount = parsed.length; + const sessionKey = session.toString(); + const seen = new Set(); + const result: IPersistedPeerChat[] = []; + for (let index = 0; index < entryCount; index++) { + const value = parsed[index]; + if (!isRecord(value) || typeof value.uri !== 'string') { + this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with no chat URI`); + continue; + } + if (seen.has(value.uri)) { + this._logService.warn(`[AgentService] Skipping duplicate peer-chat catalog entry ${index}`); + continue; + } + let owner: string; + try { + owner = parseRequiredSessionUriFromChatUri(value.uri); + } catch (error) { + this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid chat URI: ${toErrorMessage(error)}`); + continue; + } + if (owner !== sessionKey || isDefaultChatUri(value.uri)) { + this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} that is not owned by ${sessionKey}`); + continue; + } + if (value.providerData !== undefined && typeof value.providerData !== 'string') { + this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid provider data`); + continue; + } + if (value.inheritedTurnId !== undefined && typeof value.inheritedTurnId !== 'string') { + this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid inherited turn id`); + continue; + } + const originValue = toSerializableJsonValue(value.origin); + const origin = fromCatalogChatOrigin(originValue); + if (value.origin !== undefined && !origin) { + this._logService.warn(`[AgentService] Dropping invalid origin from peer-chat catalog entry ${index}`); + } + seen.add(value.uri); + result.push({ + uri: value.uri, + ...(typeof value.providerData === 'string' ? { providerData: value.providerData } : {}), + ...(origin ? { origin } : {}), + ...(typeof value.inheritedTurnId === 'string' ? { inheritedTurnId: value.inheritedTurnId } : {}), + }); + } + return result; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index ab933dbf73bce2..6337a7da4005c3 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -76,6 +76,8 @@ interface ITitlePromptContext { export interface IAgentHostSessionTitleControllerOptions { readonly sessionDataService: ISessionDataService; + readonly queueCatalogSync?: (session: ProtocolURI, metadataOverrides: Readonly>) => void; + readonly persistSurfacedSessionTitle?: (session: ProtocolURI, title: string) => Promise; readonly getGitHubCopilotToken?: () => string | undefined; readonly getGitHubToken?: () => string | undefined; readonly getGitHubHost?: () => string | undefined; @@ -98,7 +100,7 @@ export interface IAgentHostSessionTitleController { cancelTitleGeneration(session: ProtocolURI): void; clearSession(session: ProtocolURI, chatChannels: readonly ProtocolURI[]): void; markTitleAuto(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, title: string): void; - markTitleRenamed(channel: ProtocolURI, chatChannel?: ProtocolURI): void; + markTitleRenamed(channel: ProtocolURI, chatChannel?: ProtocolURI, title?: string): void; prepareInstructionForAgent(channel: ProtocolURI, chatChannel: ProtocolURI): Promise; } @@ -253,16 +255,36 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen /** Persists `title` as the custom title of the addressed independent chat or session. */ private _persistAutoTitle(channel: ProtocolURI, independentChat: ProtocolURI | undefined, title: string): void { if (independentChat) { + this._persistSessionFlag(independentChat, SESSION_CUSTOM_TITLE_KEY, title); + this._persistSessionFlag(independentChat, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_AUTO); this._persistSessionFlag(channel, customChatTitleMetadataKey(independentChat), title); this._persistSessionFlag(channel, customChatTitleSourceMetadataKey(independentChat), AGENT_HOST_TITLE_SOURCE_AUTO); + this._options.queueCatalogSync?.(channel, { + [customChatTitleMetadataKey(independentChat)]: title, + [customChatTitleSourceMetadataKey(independentChat)]: AGENT_HOST_TITLE_SOURCE_AUTO, + }); return; } + const defaultChat = this._stateManager.getSessionState(channel)?.defaultChat; + if (defaultChat) { + this._persistSessionFlag(defaultChat, SESSION_CUSTOM_TITLE_KEY, title); + this._persistSessionFlag(defaultChat, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_AUTO); + } this._persistSessionFlag(channel, SESSION_CUSTOM_TITLE_KEY, title); this._persistSessionFlag(channel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_AUTO); } private _persistAutoTitleSource(channel: ProtocolURI, independentChat: ProtocolURI | undefined): void { - this._persistSessionFlag(channel, independentChat ? customChatTitleSourceMetadataKey(independentChat) : SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_AUTO); + if (independentChat) { + this._persistSessionFlag(independentChat, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_AUTO); + this._persistSessionFlag(channel, customChatTitleSourceMetadataKey(independentChat), AGENT_HOST_TITLE_SOURCE_AUTO); + return; + } + const defaultChat = this._stateManager.getSessionState(channel)?.defaultChat; + if (defaultChat) { + this._persistSessionFlag(defaultChat, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_AUTO); + } + this._persistSessionFlag(channel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_AUTO); } /** The live title of the addressed independent chat or session. */ @@ -464,7 +486,9 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen '', title => this._applyExternalSessionTitle(session, title), () => true, - title => this._persistAutoTitle(session, undefined, title), + title => this._options.persistSurfacedSessionTitle + ? this._options.persistSurfacedSessionTitle(session, title) + : this._persistAutoTitle(session, undefined, title), ); } @@ -499,12 +523,19 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen this._persistAutoTitle(channel, independentChat, title); } - markTitleRenamed(channel: ProtocolURI, chatChannel?: ProtocolURI): void { - const key = this._independentChatChannel(channel, chatChannel) ?? channel; + markTitleRenamed(channel: ProtocolURI, chatChannel?: ProtocolURI, title?: string): void { + const independentChat = this._independentChatChannel(channel, chatChannel); + const key = independentChat ?? channel; this._cancelTitleGeneration(key); this._autoTitles.delete(key); this._provisionalTitles.delete(key); this._renamedTitles.add(key); + if (independentChat && title !== undefined) { + this._options.queueCatalogSync?.(channel, { + [customChatTitleMetadataKey(independentChat)]: title, + [customChatTitleSourceMetadataKey(independentChat)]: AGENT_HOST_TITLE_SOURCE_USER, + }); + } } async prepareInstructionForAgent(channel: ProtocolURI, chatChannel: ProtocolURI): Promise { @@ -538,7 +569,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen fallbackTitle: string, apply: (title: string) => void, currentTitleMatchesFallback: () => boolean, - persist: (title: string) => void, + persist: (title: string) => void | Promise, ): void { void this._startTitleGeneration(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist); } @@ -550,7 +581,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen fallbackTitle: string, apply: (title: string) => void, currentTitleMatchesFallback: () => boolean, - persist: (title: string) => void, + persist: (title: string) => void | Promise, ): Promise { this._cancelTitleGeneration(key); const source = new CancellationTokenSource(); @@ -573,7 +604,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen fallbackTitle: string, apply: (title: string) => void, currentTitleMatchesFallback: () => boolean, - persist: (title: string) => void, + persist: (title: string) => void | Promise, token: CancellationToken, ): Promise { const generatedTitle = await this._generateTitleFromPrompt(prompt, token); @@ -588,7 +619,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen if (generatedTitle !== fallbackTitle) { apply(generatedTitle); } - persist(generatedTitle); + await persist(generatedTitle); } private async _generateTitleFromPrompt(prompt: ITitlePromptContext, token: CancellationToken): Promise { diff --git a/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts new file mode 100644 index 00000000000000..5371097cb40209 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts @@ -0,0 +1,335 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Limiter } from '../../../base/common/async.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentProvider } from '../common/agent.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION } from './agentHostCatalogProjection.js'; +import { AgentHostCatalogDatabaseReference, AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.js'; +import { AgentHostSessionsV2ExclusionReason, IAgentHostDatabase, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; + +const IMPORT_CONCURRENCY = 4; + +export interface IAgentHostSessionsV2ProviderCandidate { + readonly session: URI; + readonly startTime: number; + readonly fingerprint: string; + readonly value: T; +} + +export interface IAgentHostSessionsV2Candidate { + readonly session: URI; + readonly current: IAgentHostDatabaseSession | undefined; + readonly legacy: IAgentHostDatabaseSession | undefined; + readonly catalog: IAgentHostDatabaseSessionV2Receipt | undefined; + readonly provider: IAgentHostSessionsV2ProviderCandidate | undefined; + readonly exclusion: IAgentHostDatabaseSessionsV2Exclusion | undefined; +} + +export interface IAgentHostSessionsV2Exclusion { + readonly reason: AgentHostSessionsV2ExclusionReason; + readonly fingerprint: string; +} + +export type AgentHostSessionsV2CandidateResolution = + | ({ readonly status: 'excluded' } & IAgentHostSessionsV2Exclusion) + | { readonly status: 'incomplete' } + | { + readonly status: 'ready'; + readonly identity: IAgentHostDatabaseSessionOptions; + readonly external: boolean; + readonly requestFactory: (database: AgentHostCatalogDatabaseReference | undefined) => Promise; + readonly valueFromRequest: (request: IAgentHostCatalogSyncRequest) => T; + }; + +export interface IAgentHostSessionsV2ImportedCandidate { + readonly session: URI; + readonly external: boolean; + readonly value: T; +} + +export interface IAgentHostSessionsV2MigrationReport { + readonly skipped: number; + readonly synchronized: number; + readonly excluded: number; + readonly incomplete: number; + readonly failed: number; + readonly staleExclusions: number; + readonly marked: boolean; + readonly imported: readonly IAgentHostSessionsV2ImportedCandidate[]; +} + +type AgentHostSessionsV2MigrationStatus = 'skipped' | 'synchronized' | 'excluded' | 'incomplete' | 'failed' | 'staleExclusion'; + +interface IAgentHostSessionsV2MigrationOutcome { + readonly status: AgentHostSessionsV2MigrationStatus; + readonly imported?: IAgentHostSessionsV2ImportedCandidate; +} + +export class AgentHostSessionsV2MigrationService { + + constructor( + private readonly _database: IAgentHostDatabase, + private readonly _sessionDataService: ISessionDataService, + private readonly _catalogSyncService: AgentHostCatalogSyncService, + private readonly _logService: ILogService, + ) { } + + async migrateProvider( + provider: AgentProvider, + enumerate: () => Promise[] | undefined>, + getPermanentExclusion: (candidate: IAgentHostSessionsV2Candidate) => IAgentHostSessionsV2Exclusion | undefined, + resolve: (candidate: IAgentHostSessionsV2Candidate) => Promise>, + force = false, + ): Promise | undefined> { + const wasBackfilled = await this._database.isSessionsV2Backfilled(provider, AGENT_HOST_CATALOG_PAYLOAD_VERSION); + const providerCandidates = !force && wasBackfilled ? [] : await enumerate(); + if (providerCandidates === undefined) { + return undefined; + } + + const [currentRegistrations, currentCatalog, legacyRegistrations, exclusions] = await Promise.all([ + this._database.listSessionV2RegistrationsForImport(), + this._database.listSessionsV2Receipts(), + this._database.listSessions(), + this._database.listSessionsV2Exclusions(provider), + ]); + const candidates = new Map>(); + const getCandidate = (session: string): IAgentHostSessionsV2Candidate => { + let candidate = candidates.get(session); + if (!candidate) { + candidate = { + session: URI.parse(session), + current: undefined, + legacy: undefined, + catalog: undefined, + provider: undefined, + exclusion: undefined, + }; + candidates.set(session, candidate); + } + return candidate; + }; + for (const current of currentRegistrations) { + candidates.set(current.session, { ...getCandidate(current.session), current }); + } + for (const catalog of currentCatalog) { + candidates.set(catalog.session, { ...getCandidate(catalog.session), catalog }); + } + for (const legacy of legacyRegistrations) { + candidates.set(legacy.session, { ...getCandidate(legacy.session), legacy }); + } + for (const providerCandidate of providerCandidates) { + const session = providerCandidate.session.toString(); + candidates.set(session, { ...getCandidate(session), provider: providerCandidate }); + } + for (const exclusion of exclusions) { + candidates.set(exclusion.session, { ...getCandidate(exclusion.session), exclusion }); + } + + const providerCandidatesToMigrate = [...candidates.values()].filter(candidate => this._belongsToProvider(candidate, provider)); + const selectedCandidates = !force && wasBackfilled + ? providerCandidatesToMigrate.filter(candidate => { + if (candidate.exclusion) { + return false; + } + return (!candidate.current && !!candidate.legacy) + || (!!candidate.current && ( + candidate.current.external === undefined + || (!!candidate.legacy && candidate.legacy.external !== undefined && candidate.legacy.modifiedTime > candidate.current.modifiedTime) + || (!!candidate.legacy && candidate.legacy.external !== undefined && !this._registrationsEqual(candidate.current, candidate.legacy)) + || !candidate.catalog + || candidate.catalog.payloadVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION + )); + }) + : providerCandidatesToMigrate; + const limiter = new Limiter>(IMPORT_CONCURRENCY); + const outcomes = await Promise.all(selectedCandidates + .sort((a, b) => a.session.toString().localeCompare(b.session.toString())) + .map(candidate => limiter.queue(() => this._migrateCandidate( + provider, + candidate, + getPermanentExclusion, + resolve, + !wasBackfilled || force, + )))); + const report: IAgentHostSessionsV2MigrationReport = { + skipped: outcomes.filter(outcome => outcome.status === 'skipped').length, + synchronized: outcomes.filter(outcome => outcome.status === 'synchronized').length, + excluded: outcomes.filter(outcome => outcome.status === 'excluded').length, + incomplete: outcomes.filter(outcome => outcome.status === 'incomplete').length, + failed: outcomes.filter(outcome => outcome.status === 'failed').length, + staleExclusions: outcomes.filter(outcome => outcome.status === 'staleExclusion').length, + marked: false, + imported: outcomes.flatMap(outcome => outcome.imported ? [outcome.imported] : []), + }; + if (report.incomplete === 0 && report.failed === 0 && report.staleExclusions === 0) { + if (!wasBackfilled) { + await this._database.markSessionsV2Backfilled(provider, AGENT_HOST_CATALOG_PAYLOAD_VERSION); + } + return { ...report, marked: true }; + } + return report; + } + + private async _migrateCandidate( + provider: AgentProvider, + candidate: IAgentHostSessionsV2Candidate, + getPermanentExclusion: (candidate: IAgentHostSessionsV2Candidate) => IAgentHostSessionsV2Exclusion | undefined, + resolve: (candidate: IAgentHostSessionsV2Candidate) => Promise>, + enumerated: boolean, + ): Promise> { + const session = candidate.session.toString(); + try { + if (await this._database.isSessionTombstoned(session)) { + return { status: 'excluded' }; + } + const priorProviderExclusion = candidate.current && candidate.current.provider !== provider + ? await this._database.getSessionsV2Exclusion(candidate.current.provider, session) + : undefined; + if (priorProviderExclusion) { + return { status: 'excluded' }; + } + if (candidate.exclusion && this._isExclusionCurrent(candidate.exclusion, candidate.provider)) { + return { status: 'excluded' }; + } + if (candidate.exclusion) { + await this._database.clearSessionsV2Exclusion(candidate.exclusion.provider, session); + } + const permanentExclusion = getPermanentExclusion(candidate); + if (permanentExclusion) { + return { status: await this._exclude(provider, candidate, permanentExclusion) }; + } + const hasMatchingReceipt = candidate.catalog ? await this._hasMatchingReceipt(candidate.session, candidate.catalog) : false; + let effectiveCandidate = candidate; + if (candidate.current && candidate.legacy && candidate.legacy.external !== undefined + && (candidate.legacy.modifiedTime > candidate.current.modifiedTime || !this._registrationsEqual(candidate.current, candidate.legacy)) + && !this._isLaterExplicitCurrentIncarnation(candidate.current, candidate.legacy, hasMatchingReceipt)) { + const reconciled = await this._database.reconcileSessionV2RegistrationFromLegacy(session, candidate.legacy); + effectiveCandidate = { ...candidate, current: reconciled }; + if (candidate.legacy.external !== undefined && hasMatchingReceipt) { + return { status: 'synchronized' }; + } + } + if (effectiveCandidate.current?.external !== undefined && effectiveCandidate.catalog && hasMatchingReceipt) { + return { status: 'skipped' }; + } + + const resolution = await resolve(effectiveCandidate); + if (resolution.status === 'excluded') { + return { status: await this._exclude(provider, effectiveCandidate, resolution) }; + } + if (resolution.status === 'incomplete') { + if (enumerated && !effectiveCandidate.provider && !effectiveCandidate.catalog && (effectiveCandidate.current || effectiveCandidate.legacy)) { + return { status: await this._exclude(provider, effectiveCandidate, { reason: 'providerAbsent', fingerprint: 'enumeration-v1' }) }; + } + return { status: 'incomplete' }; + } + + const shouldReportImported = !candidate.catalog; + if (!effectiveCandidate.current) { + const registered = await this._database.registerSessionV2(session, resolution.identity, { checkTombstone: true }); + if (!registered) { + return { status: 'excluded' }; + } + } else if (effectiveCandidate.current.external === undefined) { + await this._database.updateSessionV2External([{ session, external: resolution.external }]); + } + + let request: IAgentHostCatalogSyncRequest | undefined; + const result = await this._catalogSyncService.synchronizeMigrationWithFactory(candidate.session, async database => { + request = await resolution.requestFactory(database); + return request; + }); + return result.status === 'acknowledged' + ? { + status: 'synchronized', + ...(shouldReportImported && request ? { + imported: { + session: candidate.session, + external: resolution.external, + value: resolution.valueFromRequest(request), + }, + } : {}), + } + : { status: 'incomplete' }; + } catch (error) { + this._logService.warn(`[AgentHostSessionsV2Migration] Failed to import ${session}`, error); + return { status: 'failed' }; + } + } + + private _belongsToProvider(candidate: IAgentHostSessionsV2Candidate, provider: AgentProvider): boolean { + if (candidate.provider) { + return true; + } + if (candidate.current && candidate.legacy && candidate.legacy.external !== undefined && !this._registrationsEqual(candidate.current, candidate.legacy)) { + return candidate.legacy.provider === provider; + } + return (candidate.current?.provider ?? candidate.legacy?.provider ?? candidate.catalog?.provider ?? candidate.exclusion?.provider) === provider; + } + + private _registrationsEqual(a: IAgentHostDatabaseSession, b: IAgentHostDatabaseSession): boolean { + return a.provider === b.provider + && a.startTime === b.startTime + && a.external === b.external + && a.source === b.source; + } + + private _isLaterExplicitCurrentIncarnation(current: IAgentHostDatabaseSession, legacy: IAgentHostDatabaseSession, hasMatchingReceipt: boolean): boolean { + return hasMatchingReceipt + && current.source === 'explicit' + && current.startTime > legacy.startTime; + } + + private _isExclusionCurrent( + exclusion: IAgentHostDatabaseSessionsV2Exclusion, + providerCandidate: IAgentHostSessionsV2ProviderCandidate | undefined, + ): boolean { + switch (exclusion.reason) { + case 'backing': + case 'subagent': + return true; + case 'providerAbsent': + return providerCandidate === undefined; + case 'staleExternal': + return providerCandidate === undefined || providerCandidate.fingerprint === exclusion.fingerprint; + } + } + + private async _exclude(provider: AgentProvider, candidate: IAgentHostSessionsV2Candidate, exclusion: IAgentHostSessionsV2Exclusion): Promise<'excluded' | 'staleExclusion'> { + const result = await this._database.excludeSessionV2({ + provider, + session: candidate.session.toString(), + reason: exclusion.reason, + fingerprint: exclusion.fingerprint, + }, { + identity: candidate.current, + catalog: candidate.catalog && { + sessionGeneration: candidate.catalog.sessionGeneration, + sourceRevision: candidate.catalog.sourceRevision, + payloadHash: candidate.catalog.payloadHash, + }, + }); + return result === 'excluded' ? 'excluded' : 'staleExclusion'; + } + + private async _hasMatchingReceipt(session: URI, catalog: IAgentHostDatabaseSessionV2Receipt): Promise { + if (catalog.payloadVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION) { + return false; + } + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return false; + } + try { + return matchesAcknowledgedCatalogReceipt(await ref.object.getCatalogSyncSnapshot(), catalog); + } finally { + ref.dispose(); + } + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 9089c59bf1ff1d..8e5a63ce79ea3e 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -5,7 +5,7 @@ import { open, unlink, type FileHandle } from 'fs/promises'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; -import { Barrier, DeferredPromise, disposableTimeout, Limiter, ResourceQueue } from '../../../base/common/async.js'; +import { Barrier, DeferredPromise, disposableTimeout, Limiter, ResourceQueue, SequencerByKey } 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, toDisposable } from '../../../base/common/lifecycle.js'; @@ -19,7 +19,7 @@ import { localize } from '../../../nls.js'; import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; +import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentLegacyChat, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; import { type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; @@ -37,7 +37,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_EHCLI_LAST_TURN_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageRequestHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -55,7 +55,7 @@ import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateM import { type IAgentHostAutomationExecution, IAgentHostAutomationService } from './agentHostAutomationService.js'; import { createAgentChatContext } from './agentChatContext.js'; import { AgentHostDebugLogsCollector, type IAgentHostDebugLogsEnvironment } from './agentHostDebugLogs.js'; -import { IAgentHostDatabase } from './agentHostDatabase.js'; +import { IAgentHostDatabase, IAgentHostDatabaseSessionOptions, type IAgentHostDatabaseSessionsV2Exclusion } from './agentHostDatabase.js'; import { AgentSessionRegistry, IRegisteredSession, IStoredRegisteredSession } from './agentSessionRegistry.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { IAgentHostSubscriptionService, resolveAgentHostSession } from '../common/agentHostSubscriptionService.js'; @@ -65,9 +65,15 @@ import { AgentSessionResidency } from './agentSessionResidency.js'; import { IAgentHostSessionOpenTelemetry, type IAgentHostSessionOpenTelemetryScope } from './agentHostSessionOpenTelemetry.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, type ISessionServerToolAccessor, validateRenameTitle } from './shared/sessionServerTools.js'; -import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts, type ISessionArtifact } from '../common/sessionArtifacts.js'; +import { AgentHostCatalogDatabaseReference, AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, AgentHostCatalogData, decodeAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; +import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult, IAgentHostCatalogReconciliationOptions } from './agentHostCatalogReconciliationService.js'; +import { IAgentHostStorageService } from './agentHostStorageService.js'; +import { AgentHostCatalogListReader, AgentHostCatalogListResult } from './agentHostCatalogListReader.js'; +import { AgentHostSessionsV2CandidateResolution, AgentHostSessionsV2MigrationService, IAgentHostSessionsV2Candidate } from './agentHostSessionsV2MigrationService.js'; import { buildWorktreeFailureNotification, IAgentHostWorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; @@ -95,8 +101,9 @@ import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsCon import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { AgentHostCatalogSourceResolver, CHAT_BACKING_METADATA_KEY, fromCatalogChatOrigin } from './agentHostCatalogSourceResolver.js'; +import { AgentHostPeerChatStore, CHAT_PROVIDER_DATA_METADATA_KEY, IPersistedPeerChat } from './agentHostPeerChatStore.js'; import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; -import { IAgentHostStorageService } from './agentHostStorageService.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -120,7 +127,7 @@ interface IRecentLocalSessionUpdate { } interface ISessionListComputation { - readonly epoch: number; + epoch: number; readonly promise: Promise; trailing?: Promise; } @@ -204,12 +211,6 @@ const RESOURCE_WATCH_GRACE_MS = 30_000; /** Bound on how long {@link AgentService.subscribe} waits for a pending subagent chat to register before giving up. */ const SUBAGENT_CHAT_PENDING_TIMEOUT_MS = 15_000; -/** - * Session-database metadata key for the orchestrator-owned catalog of - * additional peer chats. When absent, the session predates this persistence - * and a one-time migration drains the agent's legacy `*.chats` state. - */ -const PEER_CHATS_METADATA_KEY = 'peerChats'; const ANNOTATIONS_METADATA_KEY = 'annotations'; function isRecord(value: unknown): value is Record { @@ -304,30 +305,19 @@ function readPersistedAnnotationsState(value: unknown, session: string): Annotat /** Opaque provider data for the session's default chat. */ const DEFAULT_CHAT_PROVIDER_DATA_METADATA_KEY = 'defaultChatProviderData'; -/** - * Session-database metadata key written on a chat's backing SDK session. - * Marks that session as an internal chat backing so legacy enumeration never - * surfaces it as a top-level session; the value is the owning chat URI. - */ -const CHAT_BACKING_METADATA_KEY = 'peerChatBacking'; - -/** - * A single entry in the orchestrator's persisted peer-chat catalog. `uri` is - * the peer chat's channel URI; `providerData` is the opaque, agent-owned blob - * (see {@link IAgentCreateChatResult.providerData}) handed back to the agent on - * restore — the orchestrator never parses it. `providerData` may be omitted, - * in which case the agent recovers its backing from its own persistence on - * {@link IAgent.materializeChat}. `origin` records the chat's provenance - * (currently only {@link ChatOriginKind.SideChat}, carrying the source chat and - * stable source turn id) so it survives a restart; omitted for plain peer chats. - */ -interface IPersistedPeerChat { +interface ICatalogChat { readonly uri: string; - readonly providerData?: string; + readonly kind: 'default' | 'peer'; + readonly title?: string; readonly origin?: ChatOrigin; readonly inheritedTurnId?: string; } +interface ILegacyRegisteredSessionMetadata { + readonly metadata: IAgentSessionMetadata; + readonly persistedTitle?: string; +} + /** * Tracks one provider's in-flight external-chat discovery attempt. `promise` is * reassigned in place when a `force` request is chained onto an attempt that @@ -380,6 +370,7 @@ export interface IAgentServiceOptions { readonly debugLogsEnvironment?: IAgentHostDebugLogsEnvironment; readonly sessionResidencyLimit?: number; readonly sessionReleaseRetryMs?: number; + readonly catalogReconciliationOptions?: IAgentHostCatalogReconciliationOptions; } export interface IAgentServiceCallbacks { @@ -395,6 +386,12 @@ export interface IAgentServiceCallbacks { readonly restoreSession: (session: URI) => Promise; readonly sessionServerToolAccessor: ISessionServerToolAccessor; readonly artifactServerToolAccessor: IArtifactServerToolAccessor; + /** Writes list-visible session metadata through the `sessions_v2` catalog and awaits the sync receipt. */ + readonly persistListVisibleSessionState: (session: string, values: Readonly>) => Promise; + /** Queues a background `sessions_v2` catalog sync for the session, optionally with metadata overrides. */ + readonly queueCatalogSync: (session: string, values: Readonly>) => void; + /** Durably records a generated title for an unloaded surfaced session and schedules its central projection. */ + readonly persistSurfacedSessionTitle: (session: string, title: string) => Promise; } export interface IAgentServiceCallbackBinder { @@ -439,6 +436,7 @@ export class AgentService extends Disposable implements IAgentService { declare readonly _serviceBrand: undefined; private readonly _resourceWriteQueue = this._register(new ResourceQueue()); + private readonly _chatCatalogMutationSequencer = new SequencerByKey(); /** Protocol: fires when state is mutated by an action. */ private readonly _onDidAction = this._register(new Emitter()); @@ -460,28 +458,33 @@ export class AgentService extends Disposable implements IAgentService { */ private readonly _sessionRegistry: AgentSessionRegistry; private readonly _orchestratorDatabase: IAgentHostDatabase; + private readonly _catalogSyncService: AgentHostCatalogSyncService; + private readonly _catalogSourceResolver: AgentHostCatalogSourceResolver; + private readonly _peerChatStore: AgentHostPeerChatStore; + private readonly _catalogReconciliationService: AgentHostCatalogReconciliationService; + private readonly _catalogListReader: AgentHostCatalogListReader; + private readonly _sessionsV2MigrationService: AgentHostSessionsV2MigrationService; + private readonly _catalogListRepair = this._register(new MutableDisposable()); + private readonly _catalogSyncSuppressedSessions = new Set(); + private readonly _deferredCatalogMetadataOverrides = new Map>(); + private readonly _backgroundCatalogStateWrites = new Map>>(); + private readonly _peerChatCleanupRepairs = this._register(new DisposableMap()); /** Serializes durable last-modified advances emitted by live session state. */ private _sessionModifiedTimeWrites: Promise = Promise.resolve(); private readonly _recentLocalSessionUpdateSnapshot: readonly IRecentLocalSessionUpdate[]; private _recentLocalSessionUpdates: readonly IRecentLocalSessionUpdate[]; + private readonly _externalReconciliationModifiedAt = new Map(); private readonly _providerMigrations = new Map(); private readonly _initialProviderMigrations = new Map>(); + private readonly _providerDiscoveryRegistrations = new Map>(); private readonly _deferredProviderMigrations = new Set(); private readonly _readableProviderCatalogs = new Set(); /** - * Backing-session URIs (as strings) whose {@link CHAT_BACKING_METADATA_KEY} - * durable marker write kept failing after a retry in `createChat`. The chat - * itself was already created and announced successfully, so this in-process - * suppression stands in for the durable marker: it is consulted by - * {@link _readSessionRegistrationFacts} (used by external discovery) and - * by `listSessions`'s overlay filter, so the backing session is still never - * surfaced as a standalone top-level session for the lifetime of this - * process, even though its on-disk marker never persisted. A later - * successful write (e.g. from a differently-timed retry) removes the entry; - * a stale entry for a since deleted session is harmless — that URI is never - * reachable again. + * Backing-session URIs suppressed until `sessions_v2` acknowledges their + * backing state. This also suppresses backing sessions when the local + * marker cannot be persisted, so discovery never exposes them. */ private readonly _unpersistedChatBackings = new Set(); @@ -499,14 +502,6 @@ export class AgentService extends Disposable implements IAgentService { private readonly _downloadProgressInterest = new Map>(); /** AgentService-owned integrations installed for registered providers. */ private readonly _providerSubscriptions = this._register(new DisposableMap()); - /** - * Per-session tail of in-flight persisted peer-chat catalog writes, keyed by - * session URI string. Read-modify-write updates to the {@link - * PEER_CHATS_METADATA_KEY} blob are chained per session so a `createChat`, - * `disposeChat`, and `onDidChangeChatData` racing for the same - * session can't clobber each other's edits. - */ - private readonly _peerChatCatalogWrites = new Map>(); private readonly _disposingPeerChats = new Set(); private readonly _defaultChatBackingWrites = new Map>(); private readonly _authService: AgentHostAuthenticationService; @@ -611,10 +606,10 @@ export class AgentService extends Disposable implements IAgentService { @IAgentHostSubscriptionService private readonly _subscriptions: IAgentHostSubscriptionService, @INetworkDiagnosticsService private readonly _networkDiagnostics: INetworkDiagnosticsService, @IAgentEditAttributionService private readonly _editAttributionService: IAgentEditAttributionService, + @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, @IInstantiationService instantiationService: IInstantiationService, @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, - @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, ) { super(); this._authService = core.authenticationService; @@ -639,6 +634,19 @@ export class AgentService extends Disposable implements IAgentService { this._localTurns = collaborators.localTurns; this._sideEffects = collaborators.sideEffects; this._serverToolHost = collaborators.serverToolHost; + this._catalogSyncService = new AgentHostCatalogSyncService(this._sessionDataService, this._orchestratorDatabase, this._logService); + this._catalogSourceResolver = new AgentHostCatalogSourceResolver({ + isUnpersistedChatBacking: session => this._unpersistedChatBackings.has(session.toString()), + worktreeProjectFromRepositoryRoot, + }); + this._peerChatStore = new AgentHostPeerChatStore(this._orchestratorDatabase, this._sessionDataService, this._logService); + this._sessionsV2MigrationService = new AgentHostSessionsV2MigrationService( + this._orchestratorDatabase, + this._sessionDataService, + this._catalogSyncService, + this._logService, + ); + this._catalogListReader = new AgentHostCatalogListReader(this._orchestratorDatabase); this._automationService = collaborators.automationService; this._register(this._providerService.registerProviderInitializer(provider => this._initializeProvider(provider))); this._register(this._providerService.onDidRegisterProvider(provider => this._onDidRegisterProvider(provider))); @@ -693,6 +701,9 @@ export class AgentService extends Disposable implements IAgentService { restoreSession: session => this.restoreSession(session), sessionServerToolAccessor: this._createSessionServerToolAccessor(), artifactServerToolAccessor: this._createArtifactServerToolAccessor(), + persistListVisibleSessionState: (session, values) => this._persistListVisibleSessionState(URI.parse(session), values), + queueCatalogSync: (session, values) => this._queueCatalogSync(URI.parse(session), values), + persistSurfacedSessionTitle: (session, title) => this._persistSurfacedSessionTitle(URI.parse(session), title), }); this._logService.info('AgentService initialized'); this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); @@ -730,9 +741,12 @@ export class AgentService extends Disposable implements IAgentService { if (changes.modifiedAt !== undefined && this._getExternalSessionsMode() === AgentHostExternalSessionsMode.Recent && readSessionExternal(meta) - && !readSessionEhcliAdoptable(meta)) { + && !readSessionEhcliAdoptable(meta) + && this._externalReconciliationModifiedAt.get(session) !== changes.modifiedAt) { + this._externalReconciliationModifiedAt.set(session, changes.modifiedAt); this._queueSessionListReconciliation(); } + this._queueCatalogSync(URI.parse(session), {}); })); updateAgentHostTelemetryLevelFromConfig(this._telemetryService, this._stateManager.rootState.config?.values); this._register(this._stateManager.onDidChangeSessionConfig(({ session, previous, current }) => this._syncAgentMergeIndex(URI.parse(session), previous, current))); @@ -774,6 +788,16 @@ export class AgentService extends Disposable implements IAgentService { })); this._editAttributionService.setEnabled(this._stateManager.rootState.config?.values[AgentHostEditTelemetryEnabledConfigKey] !== false); this._runWhenStartupSettled('external session prune', () => this._pruneStaleExternalSessions()); + this._catalogReconciliationService = this._register(new AgentHostCatalogReconciliationService( + this._orchestratorDatabase, + this._catalogSyncService, + this._storageService, + () => this._listRegisteredSessions(), + (registered, database) => this._resolveCatalogReconciliationSource(registered, database), + this._logService, + options.catalogReconciliationOptions, + )); + this._catalogReconciliationService.schedule(); this._register(core.disposables); } @@ -865,6 +889,15 @@ export class AgentService extends Disposable implements IAgentService { /** External sessions registered without a provider title, awaiting a generated one. */ private readonly _untitledExternalSessions = new Map(); private _externalSessionTitlingQueued = false; + private readonly _backgroundInitialMigrationRetries = new Map>(); + private readonly _initialProviderMigrationsNeedingRetry = new Set(); + + async whenCatalogReconciliationIdle(): Promise { + await this._catalogReconciliationService.whenIdle(); + while (this._backgroundCatalogStateWrites.size > 0) { + await Promise.allSettled([...this._backgroundCatalogStateWrites.values()].flatMap(writes => [...writes])); + } + } /** * Queues external sessions whose provider surfaced them without a title. @@ -1060,6 +1093,7 @@ export class AgentService extends Disposable implements IAgentService { const subscriptions = new DisposableStore(); try { this._invalidateSessionList(); + this._catalogReconciliationService.schedule(); provider.setServerToolHost?.(this._serverToolHost); provider.setKnownSessionsFilter?.(sessions => this._filterKnownSessions(sessions)); // Deterministic subagent membership ordering: apply a spawned subagent's @@ -1073,8 +1107,16 @@ export class AgentService extends Disposable implements IAgentService { subscriptions.add(this._sideEffects.registerProgressListener(provider)); subscriptions.add(provider.onDidMaterializeChat(e => this._onDidMaterializeChat(e))); subscriptions.add(provider.onDidDiscoverChats(chats => { - void this._migrateAndRegisterDiscoveredChats(provider, chats).catch(err => - this._logService.warn(`[AgentService] registering discovered chats for provider ${provider.id} failed`, err)); + const previous = this._providerDiscoveryRegistrations.get(provider.id) ?? Promise.resolve(); + const registration = previous + .then(() => this._migrateAndRegisterDiscoveredChats(provider, chats)) + .then(() => { }, err => this._logService.warn(`[AgentService] registering discovered chats for provider ${provider.id} failed`, err)); + this._providerDiscoveryRegistrations.set(provider.id, registration); + void registration.finally(() => { + if (this._providerDiscoveryRegistrations.get(provider.id) === registration) { + this._providerDiscoveryRegistrations.delete(provider.id); + } + }); })); this._setupChatDiscoveryForProvider(provider); subscriptions.add(provider.onDidChangeChatData(e => this._onChatDataChanged(e))); @@ -1084,6 +1126,8 @@ export class AgentService extends Disposable implements IAgentService { this._providerSubscriptions.deleteAndDispose(provider.id); this._deferredProviderMigrations.delete(provider.id); this._readableProviderCatalogs.delete(provider.id); + this._initialProviderMigrationsNeedingRetry.delete(provider.id); + this._startedChatDiscoveryProviders.delete(provider.id); }); } catch (error) { subscriptions.dispose(); @@ -1105,8 +1149,7 @@ export class AgentService extends Disposable implements IAgentService { private _onDidRegisterProvider(provider: IAgent): void { this._registerSkillCompletionProvider(); - const initialMigration = this._ensureLegacyChatsMigrated(provider); - this._trackInitialProviderMigration(provider, initialMigration); + const initialMigration = this._trackInitialProviderMigration(provider, this._ensureSessionsV2Imported(provider)); // Persisted enablement must resume without a client opening the session. this._agentMergeRestore = this._agentMergeRestore .then(() => initialMigration) @@ -1202,7 +1245,7 @@ export class AgentService extends Disposable implements IAgentService { private _createArtifactServerToolAccessor(): IArtifactServerToolAccessor { return { isEnabled: () => this._isArtifactToolsEnabled(), - persist: (session, artifacts) => persistSessionMetadata(this._sessionDataService, this._logService, session, SESSION_ARTIFACTS_KEY, stringifySessionArtifacts(artifacts)), + persist: (session, artifacts) => this._queueCatalogSync(URI.parse(session), { [SESSION_ARTIFACTS_KEY]: stringifySessionArtifacts(artifacts) }), }; } @@ -1412,7 +1455,11 @@ export class AgentService extends Disposable implements IAgentService { throw new Error(`Invalid ${SessionServerToolName.RenameChat} input: chat must match a known non-default chat.`); } - await persistSessionMetadataValues(this._sessionDataService, session.toString(), { + await persistSessionMetadataValues(this._sessionDataService, chat.toString(), { + [SESSION_CUSTOM_TITLE_KEY]: title, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AGENT, + }); + await this._persistOrderedListVisibleSessionState(session, { [customChatTitleMetadataKey(chat.toString())]: title, [customChatTitleSourceMetadataKey(chat.toString())]: AGENT_HOST_TITLE_SOURCE_AGENT, ...(isDefaultChat ? { @@ -1438,7 +1485,11 @@ export class AgentService extends Disposable implements IAgentService { if (this._stateManager.getSessionState(session.toString())?.chats.some(candidate => candidate.resource === chat.toString())) { return true; } - const persisted = await this._readPersistedPeerChatCatalog(session); + const central = await this._readCentralChatCatalog(session); + if (central) { + return central.some(candidate => candidate.kind === 'peer' && candidate.uri === chat.toString()); + } + const persisted = await this._peerChatStore.tryRead(session); return persisted?.some(candidate => candidate.uri === chat.toString()) === true; } @@ -1485,8 +1536,120 @@ export class AgentService extends Disposable implements IAgentService { }; } - private async _getSessionMetadata(session: URI): Promise { - const registered = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); + private async _legacyRegisteredSessionMetadata(registered: IRegisteredSession): Promise { + const agent = this._providerService.getProvider(registered.provider); + if (!agent) { + return undefined; + } + const metadata = await this._registeredSessionMetadata(agent, registered.session, registered.external, registered); + if (!metadata) { + return undefined; + } + const sanitized = { ...metadata, _meta: withSessionMultiRootMetadata(metadata._meta, undefined) }; + try { + const ref = await this._sessionDataService.tryOpenDatabase(metadata.session); + if (!ref) { + return { metadata: sanitized, persistedTitle: await this._readPersistedSessionTitle(metadata.session) }; + } + try { + const session = metadata.session.toString(); + const defaultChatTitleKey = customChatTitleMetadataKey(buildDefaultChatUri(session)); + const changesetKeys = this._changesetCoordinator.getListMetadataKeys(session); + const metadataKeys: Record = changesetKeys + ? { customTitle: true, [defaultChatTitleKey]: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, [defaultChatTitleKey]: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; + const persisted = await ref.object.getMetadataObject(metadataKeys); + if (persisted[CHAT_BACKING_METADATA_KEY]) { + return undefined; + } + let updated = sanitized; + const persistedTitle = persisted.customTitle + || await this._readDefaultChatTitle(metadata.session, persisted[defaultChatTitleKey]); + if (persistedTitle) { + updated = { ...updated, summary: persistedTitle }; + } + if (persisted[AH_META_IS_READ_DB_KEY] !== undefined) { + updated = { ...updated, status: withSessionStatusFlag(updated.status ?? SessionStatus.Idle, SessionStatus.IsRead, persisted[AH_META_IS_READ_DB_KEY] === 'true') }; + } + const persistedArchived = persisted[AH_META_IS_ARCHIVED_DB_KEY] ?? persisted[AH_META_IS_DONE_DB_KEY]; + if (persistedArchived !== undefined) { + updated = { ...updated, status: withSessionStatusFlag(updated.status ?? SessionStatus.Idle, SessionStatus.IsArchived, persistedArchived === 'true') }; + } + const creationReference = parseSessionCreationReference(persisted[AH_META_CREATED_BY_SESSION_DB_KEY]); + if (creationReference) { + updated = { ...updated, _meta: withSessionCreationReference(updated._meta, creationReference) }; + } + if (persisted[META_GIT_STATE]) { + try { + const gitState = JSON.parse(persisted[META_GIT_STATE]) as ISessionGitState; + updated = { ...updated, _meta: withSessionGitState(updated._meta, gitState) }; + } catch (error) { + this._logService.warn(`[AgentService][listSessions] Failed to parse Git state for ${metadata.session}`, error); + } + } + if (persisted[META_GITHUB_STATE]) { + try { + const gitHubState = JSON.parse(persisted[META_GITHUB_STATE]) as ISessionGitHubState; + updated = { ...updated, _meta: withSessionGitHubState(updated._meta, gitHubState) }; + } catch (error) { + this._logService.warn(`[AgentService][listSessions] Failed to parse GitHub state for ${metadata.session}`, error); + } + } + if (persisted[META_SOURCE_CONTROL_STATE]) { + try { + const sourceControlState = parsePersistedSourceControlState(persisted[META_SOURCE_CONTROL_STATE]); + updated = { ...updated, _meta: withSessionSourceControlState(updated._meta, sourceControlState) }; + } catch (error) { + this._logService.warn(`[AgentService][listSessions] Failed to parse source-control state for ${metadata.session}`, error); + } + } + if (persisted[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { + updated = { ...updated, _meta: withSessionWorkspaceless(updated._meta, persisted[AH_META_WORKSPACELESS_DB_KEY] === 'true') }; + } + if (persisted[AH_META_EHCLI_ADOPTED_DB_KEY] !== undefined) { + updated = { ...updated, _meta: withSessionEhcliAdopted(updated._meta, persisted[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true') }; + } + if (persisted[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]) { + try { + const devContainerWorktree = readAgentDevContainerWorktreeMetadata({ + [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: JSON.parse(persisted[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]), + }); + if (devContainerWorktree) { + updated = { ...updated, _meta: withAgentDevContainerWorktreeMetadata(updated._meta, devContainerWorktree.handle) }; + } + } catch { } + } + const multiRoot = parseSessionMultiRootMetadata(persisted[SESSION_META_MULTI_ROOT_KEY]); + if (multiRoot) { + updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) }; + } + const artifacts = this._readPersistedArtifacts(persisted[SESSION_ARTIFACTS_KEY], session, '[AgentService][listSessions]'); + if (artifacts.length > 0) { + updated = { ...updated, _meta: withSessionArtifacts(updated._meta, artifacts) }; + } + const folderPickerDecision = parseSessionFolderPickerDecision(persisted[SESSION_META_FOLDER_PICKER_KEY]); + if (folderPickerDecision) { + updated = { ...updated, _meta: withSessionFolderPickerDecision(updated._meta, folderPickerDecision) }; + } + const worktreeProject = worktreeProjectFromRepositoryRoot(persisted[WORKTREE_META_REPOSITORY_ROOT]); + if (worktreeProject) { + updated = { ...updated, project: worktreeProject }; + } + return { + metadata: this._changesetCoordinator.decorateListEntry(updated, persisted as Record), + persistedTitle, + }; + } finally { + ref.dispose(); + } + } catch (error) { + this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${metadata.session}`, error); + return { metadata: sanitized, persistedTitle: await this._readPersistedSessionTitle(metadata.session) }; + } + } + + private async _getSessionMetadata(session: URI, registeredOverride?: IRegisteredSession): Promise { + const registered = registeredOverride ?? await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); if (!registered) { return undefined; } @@ -1508,14 +1671,17 @@ export class AgentService extends Disposable implements IAgentService { return this._registeredSessionMetadata(agent, session, registered.external, registered); } - private _withLiveSessionMetadata(metadata: IAgentSessionMetadata, liveSummary: SessionSummary): IAgentSessionMetadata { + private _withLiveSessionMetadata(metadata: IAgentSessionMetadata, liveSummary: SessionSummary, trustLiveMultiRoot = true, trustLiveTitle = true): IAgentSessionMetadata { let _meta = liveSummary._meta !== undefined || metadata._meta !== undefined ? { ...metadata._meta, ...liveSummary._meta } : undefined; - _meta = withSessionMultiRootMetadata(_meta, readSessionMultiRootMetadata(liveSummary._meta) ?? readSessionMultiRootMetadata(metadata._meta)); + const liveMultiRoot = trustLiveMultiRoot + ? readSessionMultiRootMetadata(liveSummary._meta) + : undefined; + _meta = withSessionMultiRootMetadata(_meta, liveMultiRoot ?? readSessionMultiRootMetadata(metadata._meta)); return { ...metadata, - summary: liveSummary.title || metadata.summary, + summary: trustLiveTitle ? liveSummary.title || metadata.summary : metadata.summary || liveSummary.title, status: liveSummary.status, activity: liveSummary.activity, modifiedTime: Date.parse(liveSummary.modifiedAt), @@ -1531,6 +1697,191 @@ export class AgentService extends Disposable implements IAgentService { }; } + private _queueCatalogSync(session: URI, metadataOverrides: Readonly>): void { + const sessionKey = session.toString(); + if (this._catalogSyncService.isSessionDeletionFenced(session)) { + return; + } + if (this._catalogSyncSuppressedSessions.has(sessionKey)) { + if (Object.keys(metadataOverrides).length > 0) { + this._deferredCatalogMetadataOverrides.set(sessionKey, { + ...this._deferredCatalogMetadataOverrides.get(sessionKey), + ...metadataOverrides, + }); + } + return; + } + if (this._stateManager.getSurfacedSessionSummary(sessionKey) + || !this._stateManager.getSessionState(sessionKey)) { + return; + } + let writes = this._backgroundCatalogStateWrites.get(sessionKey); + if (!writes) { + writes = new Set(); + this._backgroundCatalogStateWrites.set(sessionKey, writes); + } + const write = this._persistListVisibleSessionStateNow(session, metadataOverrides); + writes.add(write); + const clear = () => { + writes.delete(write); + if (writes.size === 0) { + this._backgroundCatalogStateWrites.delete(sessionKey); + } + }; + void write.then(clear, error => { + clear(); + this._logService.warn(`[AgentService] Failed to persist list-visible session state for ${session.toString()}`, error); + }); + } + + private async _persistListVisibleSessionState(session: URI, metadataOverrides: Readonly>, chatsOverride?: readonly ICatalogChat[]): Promise { + await this._persistListVisibleSessionStateNow(session, metadataOverrides, chatsOverride); + } + + private async _persistSurfacedSessionTitle(session: URI, title: string): Promise { + await persistSessionMetadataValues(this._sessionDataService, session.toString(), { + [SESSION_CUSTOM_TITLE_KEY]: title, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AUTO, + }); + try { + await persistSessionMetadataValues(this._sessionDataService, buildDefaultChatUri(session), { + [SESSION_CUSTOM_TITLE_KEY]: title, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AUTO, + }); + } catch (error) { + this._logService.warn(`[AgentService] Failed to mirror generated surfaced-session title to its default chat for ${session.toString()}`, error); + } + await this._markCatalogPayloadDirty(session.toString()); + this._catalogReconciliationService.schedule(); + } + + private async _persistOrderedListVisibleSessionState(session: URI, metadataOverrides: Readonly>, chatsOverride?: readonly ICatalogChat[]): Promise { + const sessionKey = session.toString(); + const deferredOverrides = this._deferredCatalogMetadataOverrides.get(sessionKey); + this._deferredCatalogMetadataOverrides.delete(sessionKey); + const backgroundWrites = this._backgroundCatalogStateWrites.get(sessionKey); + if (backgroundWrites) { + await Promise.allSettled([...backgroundWrites]); + } + await this._persistListVisibleSessionStateNow(session, { ...deferredOverrides, ...metadataOverrides }, chatsOverride); + } + + private _flushDeferredCatalogMetadataOverrides(session: URI): void { + const sessionKey = session.toString(); + const deferredOverrides = this._deferredCatalogMetadataOverrides.get(sessionKey); + if (!deferredOverrides) { + return; + } + this._deferredCatalogMetadataOverrides.delete(sessionKey); + this._queueCatalogSync(session, deferredOverrides); + } + + private async _persistListVisibleSessionStateNow(session: URI, metadataOverrides: Readonly>, chatsOverride?: readonly ICatalogChat[]): Promise { + const sessionKey = session.toString(); + const summary = this._stateManager.getSessionSummary(sessionKey); + const state = this._stateManager.getSessionState(sessionKey); + if (!summary || !state) { + throw new Error(`Cannot persist list-visible state for unknown session ${sessionKey}`); + } + const result = await this._catalogSyncService.synchronizeWithFactory(session, database => this._catalogSourceResolver.buildCatalogSyncRequest(session, { + modifiedTime: Date.parse(summary.modifiedAt), + title: summary.title, + status: summary.status, + project: summary.project, + workingDirectories: summary.workingDirectories ?? [], + changes: summary.changes, + meta: summary._meta, + chats: chatsOverride ?? this._catalogChatsFromState(state), + }, metadataOverrides, false, database)); + if (result.status === 'pending') { + this._logService.warn(`[AgentService] Catalog synchronization for ${sessionKey} remains pending: ${result.reason}`); + } + } + + private async _resolveCatalogReconciliationSource(registered: IRegisteredSession, database: AgentHostCatalogDatabaseReference | undefined): Promise { + const agent = this._providerService.getProvider(registered.provider); + if (!agent) { + return { status: 'providerUnavailable' }; + } + const isChatBacking = await this._isChatBacking(registered.session); + const metadata = await this._getCatalogReconciliationMetadata(agent, registered, isChatBacking); + if (!metadata) { + return { status: 'providerUnavailable' }; + } + let status = metadata.status ?? SessionStatus.Idle; + let metadataFallbacks: Readonly> = {}; + let meta = metadata._meta; + let centralMeta: AgentHostCatalogData['_meta']; + const hasLiveState = this._stateManager.getSessionState(registered.session.toString()) !== undefined; + const peers = database + ? await this._readOrMigrateLegacyPeerChatCatalog(agent, registered.session, database) + : await this._readOrImportPeerChatCatalogWithoutLocalDatabase(agent, registered.session); + if (!database) { + const central = await this._orchestratorDatabase.getSessionV2(registered.session.toString()); + const decoded = central && decodeAgentHostCatalogPayload(central.payload); + if (decoded?.ok) { + status = decoded.value.data.isRead ? status | SessionStatus.IsRead : status & ~SessionStatus.IsRead; + status = decoded.value.data.isArchived ? status | SessionStatus.IsArchived : status & ~SessionStatus.IsArchived; + metadataFallbacks = hasLiveState ? {} : this._catalogMetadataFallbacks(decoded.value.data); + centralMeta = hasLiveState ? undefined : decoded.value.data._meta; + meta = { ...centralMeta, ...metadata._meta }; + } + } + return { + status: 'available', + request: await this._catalogSourceResolver.buildCatalogSyncRequest(registered.session, { + modifiedTime: metadata.modifiedTime, + title: metadata.summary, + status, + project: metadata.project ? { uri: metadata.project.uri.toString(), displayName: metadata.project.displayName } : undefined, + workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()) ?? [], + changes: metadata.changes, + meta: registered.external ? withSessionMultiRootMetadata(meta, undefined) : meta, + chats: [ + { + uri: buildDefaultChatUri(registered.session), + kind: 'default', + title: metadata.summary, + }, + ...peers.map(peer => ({ + uri: peer.uri, + kind: 'peer' as const, + origin: peer.origin, + inheritedTurnId: peer.inheritedTurnId, + })), + ], + }, {}, true, database, metadataFallbacks), + }; + } + + private async _getCatalogReconciliationMetadata(agent: IAgent, registered: IRegisteredSession, isChatBacking: boolean): Promise { + const providerMetadata = await this._registeredSessionMetadata(agent, registered.session, registered.external); + const liveSummary = this._stateManager.getSessionSummary(registered.session.toString()); + if (!providerMetadata) { + return liveSummary ? this._withLiveSessionMetadata({ + session: registered.session, + startTime: registered.startTime, + modifiedTime: Date.parse(liveSummary.modifiedAt), + }, liveSummary) : undefined; + } + if (isChatBacking || !liveSummary) { + return providerMetadata; + } + return this._withLiveSessionMetadata(providerMetadata, liveSummary); + } + + private _catalogChatsFromState(state: NonNullable>): ICatalogChat[] { + return state.chats + .filter(chat => chat.origin?.kind !== ChatOriginKind.Tool) + .map(chat => ({ + uri: chat.resource, + kind: state.defaultChat === chat.resource || isDefaultChatUri(chat.resource) ? 'default' : 'peer', + title: chat.title, + origin: chat.origin, + inheritedTurnId: this._stateManager.getChatInheritedTurnId(chat.resource), + })); + } + private _agentMergeRestore: Promise = Promise.resolve(); private _agentMergeIndexWrites: Promise = Promise.resolve(); /** Agent Merge notices waiting for a session's in-flight turn to finish. */ @@ -1633,26 +1984,51 @@ export class AgentService extends Disposable implements IAgentService { .catch(err => this._logService.warn(`[AgentService] Failed to update the Agent Merge index for ${session.toString()}`, err)); } - /** - * Awaits legacy migration started at provider registration. Provider-owned - * discovery is independent and surfaces unknown chats additively. - */ + /** Awaits provider discovery only when no persisted registry can serve the first list. */ private async _awaitInitialProviderMigration(): Promise { await Promise.all(this._providerService.getProviders().map(provider => this._awaitInitialProviderMigrationForProvider(provider))); } + private _retryInitialProviderMigrationsInBackground(shouldRetry: (provider: AgentProvider) => boolean): void { + for (const provider of this._providerService.getProviders()) { + if (!shouldRetry(provider.id) || this._backgroundInitialMigrationRetries.has(provider.id)) { + continue; + } + if (!this._firstListingServed && this._deferredProviderMigrations.has(provider.id)) { + continue; + } + if (this._providerMigrations.has(provider.id) && !this._initialProviderMigrationsNeedingRetry.has(provider.id)) { + continue; + } + const migration = this._initialProviderMigrationsNeedingRetry.has(provider.id) + ? this._trackInitialProviderMigration(provider, this._ensureSessionsV2Imported(provider, true)) + : this._awaitInitialProviderMigrationForProvider(provider); + const retry = migration.then( + () => { }, + error => { + this._logService.warn(`[AgentService] Background catalog migration retry failed for ${provider.id}`, error); + }, + ); + const tracked = retry.finally(() => { + if (this._backgroundInitialMigrationRetries.get(provider.id) === tracked) { + this._backgroundInitialMigrationRetries.delete(provider.id); + } + }); + this._backgroundInitialMigrationRetries.set(provider.id, tracked); + } + } + /** - * Awaits the registration-time legacy migration for a single provider, + * Awaits the registration-time direct import for a single provider, * retrying once if that initial catalog pass was unavailable. Rejects only if * the retry also fails. Restore uses this to wait for its own provider's - * catalog before reading per-session metadata, mirroring what - * {@link _awaitInitialProviderMigration} does for `listSessions`. + * catalog before reading per-session metadata. */ private async _awaitInitialProviderMigrationForProvider(provider: IAgent, requireReadableCatalog = false): Promise { const migration = this._initialProviderMigrations.get(provider.id); if (!migration) { if (requireReadableCatalog || this._deferredProviderMigrations.has(provider.id)) { - await this._ensureLegacyChatsMigrated(provider, requireReadableCatalog); + await this._ensureSessionsV2Imported(provider, requireReadableCatalog); } return this._readableProviderCatalogs.has(provider.id); } @@ -1663,22 +2039,23 @@ export class AgentService extends Disposable implements IAgentService { await this._replaceFailedInitialProviderMigration(provider, migration); } if (requireReadableCatalog && !this._readableProviderCatalogs.has(provider.id)) { - await this._ensureLegacyChatsMigrated(provider, true); + await this._ensureSessionsV2Imported(provider, true); } else if (this._firstListingServed && this._deferredProviderMigrations.has(provider.id)) { - await this._ensureLegacyChatsMigrated(provider); + await this._ensureSessionsV2Imported(provider); } + await this._providerDiscoveryRegistrations.get(provider.id); return this._readableProviderCatalogs.has(provider.id); } private async _migrateAndRegisterDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise { if (this._deferredProviderMigrations.has(provider.id)) { try { - await this._ensureLegacyChatsMigrated(provider, true); + await this._ensureSessionsV2Imported(provider, true); } catch (err) { this._logService.warn(`[AgentService] registry migration: failed for provider ${provider.id} after chat discovery`, err); } } - await this._registerDiscoveredChats(provider, chats); + await this._registerDiscoveredChats(provider, chats, false); } private _replaceFailedInitialProviderMigration(provider: IAgent, failed: Promise): Promise { @@ -1686,7 +2063,7 @@ export class AgentService extends Disposable implements IAgentService { if (current !== failed) { return current ?? Promise.resolve(); } - const retry = this._ensureLegacyChatsMigrated(provider, true); + const retry = this._ensureSessionsV2Imported(provider, true); return this._trackInitialProviderMigration(provider, retry); } @@ -1712,8 +2089,8 @@ export class AgentService extends Disposable implements IAgentService { * is likewise chained onto a further follow-up rather than being * coalesced away as a supposed duplicate. */ - private _ensureLegacyChatsMigrated(provider: IAgent, force = false): Promise { - return this._ensureProviderCatalog(provider, this._providerMigrations, force, runForce => this._migrateLegacyProviderChats(provider, runForce)); + private _ensureSessionsV2Imported(provider: IAgent, force = false): Promise { + return this._ensureProviderCatalog(provider, this._providerMigrations, force, runForce => this._importProviderSessionsV2(provider, runForce)); } private _ensureProviderCatalog( @@ -1781,14 +2158,25 @@ export class AgentService extends Disposable implements IAgentService { * next readiness signal retries. */ - private async _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise { - // Keys and durable recency only: discovery arrives in batches, and the - // full listing re-runs the per-row provenance migration for every - // registered session each time. The recency snapshot lets the - // already-registered branch skip a per-session write when the provider - // re-reports an unchanged modified time (the common case on startup). - const registeredRecency = await this._sessionRegistry.listSessionModifiedTimes(); - const registeredKeys = new Set(registeredRecency.keys()); + private async _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[], awaitReconciliation = true): Promise { + // Keys only: discovery arrives in batches, and the full listing re-runs the + // per-row provenance migration for every registered session each time. + const [runtimeCompatibleKeys, registeredRecency, persistedExclusions] = await Promise.all([ + this._sessionRegistry.listRuntimeCompatibleSessionKeys(), + this._sessionRegistry.listSessionModifiedTimes(), + this._sessionRegistry.listSessionsV2Exclusions(provider.id), + ]); + const registeredKeys = new Set(runtimeCompatibleKeys); + const exclusions = new Map(persistedExclusions.map(exclusion => [exclusion.session, exclusion])); + const exclusionsToMark: IAgentHostDatabaseSessionsV2Exclusion[] = []; + const queueExclusion = (exclusion: IAgentHostDatabaseSessionsV2Exclusion): void => { + const existing = exclusions.get(exclusion.session); + if (existing?.reason === exclusion.reason && existing.fingerprint === exclusion.fingerprint) { + return; + } + exclusions.set(exclusion.session, exclusion); + exclusionsToMark.push(exclusion); + }; const discoveryLimiter = new Limiter(4); let suppressed = 0; let skippedAsStale = 0; @@ -1802,18 +2190,32 @@ export class AgentService extends Disposable implements IAgentService { const session = sessionMetadata.session; try { // Matching registry entries still advance their durable recency from - // the provider catalog, but need no per-session metadata I/O. Only a - // genuine forward move is queued for the batched write below, so a - // steady-state startup issues no recency writes at all. + // the provider catalog, but need no per-session metadata I/O. if (registeredKeys.has(session.toString())) { alreadyRegistered++; - const stored = registeredRecency.get(session.toString()); - if (Number.isFinite(sessionMetadata.modifiedTime) && (stored === undefined || sessionMetadata.modifiedTime > stored)) { + if ((registeredRecency.get(session.toString()) ?? 0) < sessionMetadata.modifiedTime) { modifiedTimeAdvances.push({ session, modifiedTime: sessionMetadata.modifiedTime }); } return false; } if (isSubagentSession(session.toString())) { + queueExclusion({ + provider: provider.id, + session: session.toString(), + reason: 'subagent', + fingerprint: 'uri-v1', + }); + suppressed++; + return false; + } + const persistedExclusion = exclusions.get(session.toString()); + if (persistedExclusion?.reason === 'backing' || await this._isChatBacking(session)) { + queueExclusion({ + provider: provider.id, + session: session.toString(), + reason: 'backing', + fingerprint: 'backing-v1', + }); suppressed++; return false; } @@ -1824,6 +2226,12 @@ export class AgentService extends Disposable implements IAgentService { } const external = reportedExternal && !registrationFacts.hostCreated; if (external && !readSessionEhcliAdoptable(sessionMetadata._meta) && this._isExternalSessionOlderThanMaxAge(sessionMetadata.modifiedTime, Date.now())) { + queueExclusion({ + provider: provider.id, + session: session.toString(), + reason: 'staleExternal', + fingerprint: String(sessionMetadata.modifiedTime), + }); skippedAsStale++; return false; } @@ -1833,20 +2241,27 @@ export class AgentService extends Disposable implements IAgentService { `discovery registration for ${session.toString()}`, ); if (registered) { + const effectiveIdentity = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); + if (!effectiveIdentity) { + throw new Error(`Missing registered identity for discovered session ${session.toString()}`); + } + const effectiveExternal = effectiveIdentity.external; registryChanged = true; - // Only reached for a session the registry did not already hold, so its - // external read state has never been seeded. - if (external) { - await this._initializeExternalSessionReadState(session); + const syncResult = await this._catalogSyncService.synchronizeWithFactory( + session, + database => this._buildImportedCatalogSyncRequest(provider, sessionMetadata, effectiveExternal, true, database), + ); + if (syncResult.status === 'pending') { + this._logService.warn(`[AgentService] Discovered session ${session.toString()} remains incomplete: ${syncResult.reason}`); } registeredKeys.add(session.toString()); - if (external && !sessionMetadata.summary) { + if (effectiveExternal && !sessionMetadata.summary) { untitledExternal.push(sessionMetadata); } - if (external && !readSessionEhcliAdoptable(sessionMetadata._meta)) { + if (effectiveExternal && !readSessionEhcliAdoptable(sessionMetadata._meta)) { registeredExternal = true; } else { - await this._announceSurfacedSession({ ...sessionMetadata, _meta: withSessionExternal(sessionMetadata._meta, external) }, provider.id); + await this._announceSurfacedSession({ ...sessionMetadata, _meta: withSessionExternal(sessionMetadata._meta, effectiveExternal) }, provider.id); } } else { this._logService.trace(`[AgentService] discovery: ${session.toString()} was not registered (tombstoned)`); @@ -1857,19 +2272,34 @@ export class AgentService extends Disposable implements IAgentService { return false; } }))); - const registered = results.filter(changed => changed).length; if (modifiedTimeAdvances.length > 0) { - await this._retryRegistryMutation( - () => this._sessionRegistry.updateModifiedTimes(modifiedTimeAdvances), - `batched modified-time update for ${modifiedTimeAdvances.length} session(s)`, - ); - this._invalidateSessionList(); + try { + await this._retryRegistryMutation( + () => this._sessionRegistry.updateModifiedTimes(modifiedTimeAdvances), + `batched modified-time update for ${modifiedTimeAdvances.length} session(s)`, + ); + this._invalidateSessionList(); + } catch (error) { + this._logService.warn(`[AgentService] Failed to persist ${modifiedTimeAdvances.length} discovered session modified time(s); continuing discovery post-processing`, error); + } + await Promise.all(modifiedTimeAdvances.map(({ session }) => this._markCatalogPayloadDirty(session.toString()))); + this._catalogReconciliationService.schedule(); + } + try { + await this._sessionRegistry.markSessionsV2ExcludedBatch(exclusionsToMark); + } catch (error) { + this._logService.warn(`[AgentService] Failed to persist ${exclusionsToMark.length} discovery exclusion(s) for provider ${provider.id}; retrying on the next discovery pass`, error); } + const registered = results.filter(changed => changed).length; if (registryChanged) { this._invalidateSessionList(); + this._catalogReconciliationService.schedule(); } if (registeredExternal) { this._queueSessionListReconciliation(); + if (awaitReconciliation) { + await this._sessionListReconciliation; + } } if (untitledExternal.length > 0) { this._scheduleExternalSessionTitles(untitledExternal); @@ -1878,87 +2308,248 @@ export class AgentService extends Disposable implements IAgentService { return registered > 0; } - private async _migrateLegacyProviderChats(provider: IAgent, force = false): Promise { - if (!force) { - if (await this._sessionRegistry.isProviderBackfilled(provider.id)) { + private async _importProviderSessionsV2(provider: IAgent, force = false): Promise { + let deferred = false; + const report = await this._sessionsV2MigrationService.migrateProvider( + provider.id, + async () => { + const sessions = await this._enumerateLegacyProviderSessions(provider); + if (sessions === AgentChatMigrationDeferred) { + deferred = true; + return undefined; + } + return sessions?.map(session => ({ + session: session.session, + startTime: session.startTime, + fingerprint: String(session.modifiedTime), + value: session, + })); + }, + candidate => isSubagentSession(candidate.session.toString()) + ? { reason: 'subagent', fingerprint: 'uri-v1' } + : candidate.catalog?.isChatBacking === true + ? { reason: 'backing', fingerprint: candidate.catalog.payloadHash } + : undefined, + candidate => this._resolveSessionsV2ImportCandidate(provider, candidate), + force, + ); + if (!report) { + if (deferred) { + this._deferredProviderMigrations.add(provider.id); + this._readableProviderCatalogs.delete(provider.id); return; } - if (await this._sessionRegistry.isBackfilled()) { - await this._sessionRegistry.markProviderBackfilled(provider.id); - return; + this._readableProviderCatalogs.delete(provider.id); + if (!await this._sessionRegistry.isSessionsV2Backfilled(provider.id, AGENT_HOST_CATALOG_PAYLOAD_VERSION)) { + throw new ProviderCatalogUnavailableError(provider.id); } + return; } - const sessions = await this._enumerateLegacyProviderSessions(provider); - if (sessions === undefined) { - this._readableProviderCatalogs.delete(provider.id); - throw new ProviderCatalogUnavailableError(provider.id); + if (report.synchronized + report.excluded + report.incomplete + report.failed + report.staleExclusions + report.skipped > 0) { + this._invalidateSessionList(); } - if (sessions === AgentChatMigrationDeferred) { - this._deferredProviderMigrations.add(provider.id); - this._readableProviderCatalogs.delete(provider.id); - return; + if (report.incomplete + report.failed + report.staleExclusions > 0) { + this._catalogReconciliationService.schedule(); } - const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external])); - const migrationLimiter = new Limiter(4); - const identities = await Promise.all(sessions.map(s => migrationLimiter.queue(async (): Promise => { - if (isSubagentSession(s.session.toString())) { - return undefined; - } - const facts = await this._readSessionRegistrationFacts(s.session); - if (facts.chatBacking) { - return undefined; - } - const external = !facts.hostCreated; - return { session: s.session, provider: provider.id, startTime: s.startTime, modifiedTime: s.modifiedTime, 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) { - continue; - } - const metadata = sessions[index]; - if (identity.external && !readSessionEhcliAdoptable(metadata._meta) && this._isExternalSessionOlderThanMaxAge(metadata.modifiedTime, Date.now())) { - continue; - } - const registered = await this._sessionRegistry.register(identity.session, identity, { checkTombstone: true }); - if (registered) { - this._invalidateSessionList(); - if (identity.external && existing.get(identity.session.toString()) !== true) { - await this._initializeExternalSessionReadState(identity.session); - } - existing.set(identity.session.toString(), identity.external); - if (identity.external && !metadata.summary) { + let importedExternal = false; + for (const imported of report.imported) { + const metadata = { ...imported.value, _meta: withSessionExternal(imported.value._meta, imported.external) }; + if (imported.external) { + importedExternal = true; + if (!metadata.summary) { untitledExternal.push(metadata); } - if (identity.external && !readSessionEhcliAdoptable(metadata._meta)) { - registeredExternal = true; - } else { - await this._announceSurfacedSession({ ...metadata, _meta: withSessionExternal(metadata._meta, identity.external) }, provider.id); - } + } + if (!imported.external || readSessionEhcliAdoptable(metadata._meta)) { + await this._announceSurfacedSession(metadata, provider.id); } } - await this._sessionRegistry.markProviderBackfilled(provider.id); this._deferredProviderMigrations.delete(provider.id); - this._readableProviderCatalogs.add(provider.id); - this._startChatDiscovery(provider, 'legacy migration enumerated the provider catalog'); - if (registeredExternal) { + if (report.marked) { + this._readableProviderCatalogs.add(provider.id); + this._initialProviderMigrationsNeedingRetry.delete(provider.id); + } else { + // An unmarked pass left candidates unimported, so the provider's + // catalog is not yet readable; the un-set backfill marker makes the + // next pass re-enumerate rather than short-circuit. + this._readableProviderCatalogs.delete(provider.id); + this._initialProviderMigrationsNeedingRetry.add(provider.id); + } + if (!await this._sessionRegistry.isProviderBackfilled(provider.id)) { + this._startChatDiscovery(provider, 'legacy migration enumerated the provider catalog'); + } + if (importedExternal) { this._queueSessionListReconciliation(); } if (untitledExternal.length > 0) { this._scheduleExternalSessionTitles(untitledExternal); } + this._logService.info(`[AgentService] sessions_v2 import for provider ${provider.id}: ${report.synchronized} synchronized, ${report.skipped} current, ${report.excluded} excluded, ${report.staleExclusions} stale exclusions, ${report.incomplete} incomplete, ${report.failed} failed, marker ${report.marked ? 'set' : 'not set'}`); } - /** Seeds external sessions as read. Avoiding this DB requires a durable registry default. */ - private async _initializeExternalSessionReadState(session: URI): Promise { - const ref = this._sessionDataService.openDatabase(session); + private async _resolveSessionsV2ImportCandidate(provider: IAgent, candidate: IAgentHostSessionsV2Candidate): Promise> { + const session = candidate.session; + const facts = await this._readSessionRegistrationFacts(session); + if (facts.chatBacking) { + return { status: 'excluded', reason: 'backing', fingerprint: 'backing-v1' }; + } + let storedIdentity = candidate.current ?? candidate.legacy; + if (storedIdentity && storedIdentity.external === undefined) { + const external = !facts.hostCreated; + await this._orchestratorDatabase.updateRuntimeSessionExternal([{ session: session.toString(), external }]); + storedIdentity = { + ...storedIdentity, + external, + source: external ? 'discovery' : storedIdentity.source, + }; + } + const external = storedIdentity?.external ?? !facts.hostCreated; + const identity: IAgentHostDatabaseSessionOptions = storedIdentity + ? { + provider: storedIdentity.provider, + startTime: storedIdentity.startTime, + modifiedTime: storedIdentity.modifiedTime, + source: storedIdentity.external === undefined ? (external ? 'discovery' : 'restore') : storedIdentity.source, + } + : { + provider: provider.id, + startTime: candidate.provider?.startTime ?? Date.now(), + modifiedTime: candidate.provider?.startTime ?? Date.now(), + source: external ? 'discovery' : 'restore', + }; + const metadata = candidate.current + ? await this._getSessionMetadata(session, { session, ...identity, modifiedTime: identity.modifiedTime ?? identity.startTime, external }) + ?? candidate.provider?.value + ?? (candidate.legacy && candidate.current.external === undefined + ? await this._registeredSessionMetadata(provider, session, external, { startTime: identity.startTime, modifiedTime: identity.modifiedTime ?? identity.startTime }) + : undefined) + : candidate.provider?.value ?? await this._registeredSessionMetadata(provider, session, external); + if (!metadata) { + return { status: 'incomplete' }; + } + const liveSummary = this._stateManager.getSessionSummary(session.toString()); + const canonicalMetadata = !candidate.current && liveSummary ? this._withLiveSessionMetadata(metadata, liveSummary) : metadata; + if (external && !readSessionEhcliAdoptable(canonicalMetadata._meta) && this._isExternalSessionOlderThanMaxAge(canonicalMetadata.modifiedTime, Date.now())) { + return { status: 'excluded', reason: 'staleExternal', fingerprint: String(canonicalMetadata.modifiedTime) }; + } + let existingCatalog; try { - await ref.object.setMetadata(AH_META_IS_READ_DB_KEY, 'true'); - } finally { - ref.dispose(); + existingCatalog = candidate.catalog ? await this._orchestratorDatabase.getSessionV2(session.toString()) : undefined; + } catch (error) { + this._logService.warn(`[AgentService] Failed to read existing catalog state for ${session.toString()}`, error); + return { status: 'incomplete' }; + } + const decodedCatalog = existingCatalog ? decodeAgentHostCatalogPayload(existingCatalog.payload) : undefined; + const existingCatalogData = decodedCatalog?.ok ? decodedCatalog.value.data : undefined; + return { + status: 'ready', + identity, + external, + requestFactory: database => this._buildImportedCatalogSyncRequest(provider, canonicalMetadata, external, !candidate.current && !candidate.legacy, database, existingCatalogData), + valueFromRequest: request => request.data.summary === canonicalMetadata.summary + ? canonicalMetadata + : { ...canonicalMetadata, summary: request.data.summary }, + }; + } + + private async _buildImportedCatalogSyncRequest(provider: IAgent, metadata: IAgentSessionMetadata, external: boolean, seedExternalRead: boolean, database: AgentHostCatalogDatabaseReference | undefined, existingCatalogData?: AgentHostCatalogData): Promise { + const shouldSeedExternalRead = external && seedExternalRead; + const preserveCentralRead = !database && existingCatalogData !== undefined; + const preserveCentralMetadata = preserveCentralRead && !this._stateManager.getSessionState(metadata.session.toString()); + const baseStatus = metadata.status ?? SessionStatus.Idle; + const status = !preserveCentralRead + ? shouldSeedExternalRead ? baseStatus | SessionStatus.IsRead : baseStatus + : existingCatalogData.isRead ? baseStatus | SessionStatus.IsRead : baseStatus & ~SessionStatus.IsRead; + const peers = database + ? await this._readOrMigrateLegacyPeerChatCatalog(provider, metadata.session, database) + : await this._readOrImportPeerChatCatalogWithoutLocalDatabase(provider, metadata.session); + const meta = preserveCentralMetadata + ? { ...existingCatalogData._meta, ...metadata._meta } + : metadata._meta; + return this._catalogSourceResolver.buildCatalogSyncRequest(metadata.session, { + modifiedTime: metadata.modifiedTime, + title: metadata.summary, + status, + project: metadata.project ? { uri: metadata.project.uri.toString(), displayName: metadata.project.displayName } : undefined, + workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()) ?? [], + changes: metadata.changes, + meta: withSessionMultiRootMetadata(meta, undefined), + chats: [ + { + uri: buildDefaultChatUri(metadata.session), + kind: 'default', + title: metadata.summary, + }, + ...peers.map(peer => ({ + uri: peer.uri, + kind: 'peer' as const, + origin: peer.origin, + inheritedTurnId: peer.inheritedTurnId, + })), + ], + }, shouldSeedExternalRead ? { [AH_META_IS_READ_DB_KEY]: 'true' } : {}, true, database, + preserveCentralMetadata ? this._catalogMetadataFallbacks(existingCatalogData) : {}); + } + + private _catalogMetadataFallbacks(data: AgentHostCatalogData): Readonly> { + const metadata: Record = { + [AH_META_IS_READ_DB_KEY]: String(data.isRead), + [AH_META_IS_ARCHIVED_DB_KEY]: String(data.isArchived), + }; + if (data.summary !== undefined) { + metadata[SESSION_CUSTOM_TITLE_KEY] = data.summary; + } + if (data.titleSource !== undefined) { + metadata[SESSION_CUSTOM_TITLE_SOURCE_KEY] = data.titleSource; + } + for (const chat of data.chats) { + if (chat.summary !== undefined) { + metadata[customChatTitleMetadataKey(chat.uri)] = chat.summary; + } + if (chat.titleSource !== undefined) { + metadata[customChatTitleSourceMetadataKey(chat.uri)] = chat.titleSource; + } } + return metadata; + } + + private async _readOrImportPeerChatCatalogWithoutLocalDatabase(agent: IAgent, session: URI): Promise { + const central = await this._peerChatStore.tryRead(session, false); + if (central !== undefined) { + return central; + } + const cached = await this._readCachedChatCatalog(session); + const cachedPeers = cached?.filter(chat => chat.kind === 'peer'); + let legacy: readonly IAgentLegacyChat[] | undefined; + if (cachedPeers?.length !== 0 && agent.listLegacyChatBackings) { + try { + legacy = await agent.listLegacyChatBackings(session); + } catch (error) { + this._logService.warn(`[AgentService] Failed to enumerate peer-chat membership for ${session.toString()}`, error); + throw error; + } + } + const providerData = new Map(legacy?.map(chat => [chat.uri.toString(), chat.providerData]) ?? []); + const entries: IPersistedPeerChat[] | undefined = cachedPeers + ? cachedPeers.map(chat => { + const matchingProviderData = providerData.get(chat.uri); + return { + uri: chat.uri, + ...(matchingProviderData !== undefined ? { providerData: matchingProviderData } : {}), + ...(chat.origin !== undefined ? { origin: chat.origin } : {}), + ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), + }; + }) + : legacy?.map(chat => ({ + uri: chat.uri.toString(), + ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}), + })); + if (entries === undefined) { + return []; + } + await this._peerChatStore.replaceForMigration(session, entries); + return entries; } private async _isExternalProviderChat(session: URI): Promise { @@ -2012,8 +2603,19 @@ export class AgentService extends Disposable implements IAgentService { }; } + private _inFlightRegisteredSessions: Promise | undefined; + private _listRegisteredSessions(): Promise { - return this._sessionRegistry.list(entry => this._migrateRegisteredSession(entry)); + if (!this._inFlightRegisteredSessions) { + const operation = this._sessionRegistry.list(entry => this._migrateRegisteredSession(entry)); + const inFlight = operation.finally(() => { + if (this._inFlightRegisteredSessions === inFlight) { + this._inFlightRegisteredSessions = undefined; + } + }); + this._inFlightRegisteredSessions = inFlight; + } + return this._inFlightRegisteredSessions; } private async _advanceSessionModifiedTime(session: URI, modifiedTime: number, invalidate = true): Promise { @@ -2057,6 +2659,38 @@ export class AgentService extends Disposable implements IAgentService { return known; } + /** + * Whether a session is marked as an internal chat backing, either durably + * or in `_unpersistedChatBackings`. + */ + private async _isChatBacking(session: URI): Promise { + const sessionKey = session.toString(); + if (this._unpersistedChatBackings.has(sessionKey)) { + return true; + } + + try { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (ref) { + try { + if (await ref.object.getMetadata(CHAT_BACKING_METADATA_KEY)) { + return true; + } + } finally { + ref.dispose(); + } + } + } catch (error) { + this._logService.warn(`[AgentService] Failed to read chat-backing metadata for ${sessionKey}; checking the central projection`, error); + } + try { + return (await this._orchestratorDatabase.getSessionV2(sessionKey))?.isChatBacking === true; + } catch (error) { + this._logService.warn(`[AgentService] Failed to read central chat-backing projection for ${sessionKey}`, error); + return false; + } + } + /** Active list computations and their optional trailing refresh, shared per mode. */ private readonly _inFlightListSessions = new Map(); @@ -2077,7 +2711,10 @@ export class AgentService extends Disposable implements IAgentService { } if (!inFlight.trailing) { const startTrailing = () => this._startSessionListComputation(mode).promise; - inFlight.trailing = inFlight.promise.then(startTrailing, startTrailing); + inFlight.trailing = inFlight.promise.then( + result => inFlight.epoch === epoch ? result : startTrailing(), + startTrailing, + ); } return [...await inFlight.trailing]; } @@ -2106,16 +2743,18 @@ export class AgentService extends Disposable implements IAgentService { return entry; } - private async _computeSessions(mode: AgentHostExternalSessionsMode): Promise { + private async _computeSessions(mode: AgentHostExternalSessionsMode, epoch = this._registryEpoch): Promise { this._logService.trace('[AgentService] listSessions computation started'); const startedAt = Date.now(); - // The first list waits for registration-time legacy migration if it is still in flight. - await this._awaitInitialProviderMigration(); // The registry is the source of truth for top-level sessions. Internal // chat backings and subagent sessions never enter it; ephemeral sessions // are tombstoned at creation. A transiently missing provider snapshot no // longer evicts a session. - const allRegistered = await this._listRegisteredSessions(); + let allRegistered = await this._listRegisteredSessions(); + if (allRegistered.length === 0) { + await this._awaitInitialProviderMigration(); + allRegistered = await this._listRegisteredSessions(); + } // External sessions that the current mode hides outright are dropped // before any provider or database read. On a large catalogue these are // most of the registry, and each one otherwise costs a provider metadata @@ -2127,188 +2766,104 @@ export class AgentService extends Disposable implements IAgentService { const registered = hiddenExternal.size > 0 ? allRegistered.filter(entry => !hiddenExternal.has(entry.session.toString())) : allRegistered; - // Warm each involved provider's bulk metadata cache once, so the - // per-session metadata reads below are served from memory instead of one - // provider round-trip per session (the dominant cost on a large - // catalogue). Best-effort and provider-optional; disposed once the - // metadata phase completes. - const prewarmStore = new DisposableStore(); - const involvedProviders = new Map(); - for (const entry of registered) { - if (!involvedProviders.has(entry.provider)) { - const agent = this._providerService.getProvider(entry.provider); - if (agent?.prewarmSessionMetadata) { - involvedProviders.set(entry.provider, agent); - } + const providersWithRegistrations = new Set(allRegistered.map(entry => entry.provider)); + this._retryInitialProviderMigrationsInBackground(provider => !providersWithRegistrations.has(provider)); + const catalogLimiter = new Limiter<{ + readonly registeredSession: IRegisteredSession; + readonly central: AgentHostCatalogListResult; + } | undefined>(4); + const catalogResults = await Promise.all(registered.map(registeredSession => catalogLimiter.queue(async () => { + const { session } = registeredSession; + if (this._stateManager.isIdleProvisionalSession(session.toString()) || await this._isCatalogBackingProjectionPending(session)) { + return undefined; + } + return { + registeredSession, + central: await this._catalogListReader.read(registeredSession), + }; + }))); + const providersWithEligibleCatalogs = new Set(catalogResults + .filter(result => result !== undefined && (result.central.eligible || result.central.chatBacking)) + .map(result => result!.registeredSession.provider)); + const visibleProviders = new Set(registered.map(entry => entry.provider)); + this._retryInitialProviderMigrationsInBackground(provider => + visibleProviders.has(provider) + && !providersWithEligibleCatalogs.has(provider)); + const fallbackProviders = new Map(); + for (const result of catalogResults) { + if (!result || result.central.eligible || result.central.chatBacking || fallbackProviders.has(result.registeredSession.provider)) { + continue; + } + const agent = this._providerService.getProvider(result.registeredSession.provider); + if (agent?.prewarmSessionMetadata) { + fallbackProviders.set(result.registeredSession.provider, agent); } } - await Promise.all([...involvedProviders.values()].map(async agent => { + const prewarmDisposables: IDisposable[] = []; + await Promise.all([...fallbackProviders.values()].map(async agent => { try { - prewarmStore.add(await agent.prewarmSessionMetadata!()); + prewarmDisposables.push(await agent.prewarmSessionMetadata!()); } catch (err) { this._logService.warn(`[AgentService] listSessions: failed to prewarm metadata for provider ${agent.id}`, err); } })); - const metadataLimiter = new Limiter(4); - const metadataPhaseStartedAt = Date.now(); + const repairSessions = new Set(); + const persistedFallbackTitles = new Map(); + const fallbackLimiter = new Limiter(4); let results: readonly (IAgentSessionMetadata | undefined)[]; try { - results = await Promise.all(registered.map(registeredSession => metadataLimiter.queue(async (): Promise => { - const { session, provider, external } = registeredSession; - // Idle provisional sessions stay hidden until they materialize or gain - // turn activity (#321269). The state-manager overlay below re-surfaces - // them then. - if (this._stateManager.isIdleProvisionalSession(session.toString())) { + results = await Promise.all(catalogResults.map(result => fallbackLimiter.queue(async (): Promise => { + if (!result) { return undefined; } - - const agent = this._providerService.getProvider(provider); - if (!agent) { + const { registeredSession, central } = result; + const { session } = registeredSession; + if (central.eligible) { + return central.metadata; + } + if (central.chatBacking) { return undefined; } + repairSessions.add(session.toString()); + if (central.error) { + this._logService.warn(`[AgentService] Failed to read central catalog row for ${session.toString()}`, central.error); + } else { + this._logService.trace(`[AgentService] Central catalog row for ${session.toString()} is ineligible: ${central.detail}`); + } + try { - return await this._registeredSessionMetadata(agent, session, external, registeredSession); + const fallback = await this._legacyRegisteredSessionMetadata(registeredSession); + if (!fallback) { + return undefined; + } + if (fallback.persistedTitle) { + persistedFallbackTitles.set(session.toString(), fallback.persistedTitle); + } + return fallback.metadata; } catch (err) { this._logService.warn(`[AgentService] listSessions: failed to read metadata for ${session}`, err); return undefined; } }))); } finally { - prewarmStore.dispose(); - } - const flat = results.filter((s): s is IAgentSessionMetadata => s !== undefined); - const metadataPhaseMs = Date.now() - metadataPhaseStartedAt; - - // Overlay persisted custom titles from per-session databases. - const overlayLimiter = new Limiter(4); - const overlayPhaseStartedAt = Date.now(); - const overlaid = await Promise.all(flat.map(s => overlayLimiter.queue(async (): Promise => { - const sanitized = { ...s, _meta: withSessionMultiRootMetadata(s._meta, undefined) }; - // A backing session whose durable marker write kept failing is - // suppressed in-process (see `_unpersistedChatBackings`); check - // this before touching the DB so it is filtered the same way - // whether or not the marker ever made it to disk. - if (this._unpersistedChatBackings.has(s.session.toString())) { - return undefined; - } - try { - const ref = await this._sessionDataService.tryOpenDatabase(s.session); - if (!ref) { - return sanitized; - } - try { - // Batch the always-required keys (title / read / archive - // flags) with any keys the changeset coordinator asks for - // so the session DB is hit exactly once. The coordinator - // returns `undefined` when a live source can already - // answer the catalogue question, avoiding the - // potentially-large persisted blobs entirely. - const sessionStr = s.session.toString(); - const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); - const metadataKeys: Record = changesetKeys - ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } - : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_EHCLI_LAST_TURN_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; - const m = await ref.object.getMetadataObject(metadataKeys); - // This session is an internal peer-chat backing (e.g. a - // Claude peer chat's SDK session, enumerated by the agent's - // own `listSessions`). Drop it so it never leaks as a - // standalone top-level session — mirrors the subagent filter - // on the state-manager overlay path below. - if (m[CHAT_BACKING_METADATA_KEY]) { - return undefined; - } - let updated = sanitized; - if (m.customTitle) { - updated = { ...updated, summary: m.customTitle }; - } - // `isDone` is the legacy key for `isArchived`. - if (m[AH_META_IS_READ_DB_KEY] !== undefined) { - updated = { ...updated, status: withSessionStatusFlag(updated.status ?? SessionStatus.Idle, SessionStatus.IsRead, m[AH_META_IS_READ_DB_KEY] === 'true') }; - } - const persistedArchived = m[AH_META_IS_ARCHIVED_DB_KEY] ?? m[AH_META_IS_DONE_DB_KEY]; - if (persistedArchived !== undefined) { - updated = { ...updated, status: withSessionStatusFlag(updated.status ?? SessionStatus.Idle, SessionStatus.IsArchived, persistedArchived === 'true') }; - } - const creationReference = parseSessionCreationReference(m[AH_META_CREATED_BY_SESSION_DB_KEY]); - if (creationReference) { - updated = { ...updated, _meta: withSessionCreationReference(updated._meta, creationReference) }; + for (const disposable of prewarmDisposables) { + disposable.dispose(); + } + } + // A late listing can still find catalog misses after disposal (a + // queued reconciliation resolves after teardown); scheduling a repair + // then would leak the timer, since a disposed holder drops its value. + if (repairSessions.size > 0 && !this._store.isDisposed) { + this._catalogListRepair.value = disposableTimeout(() => { + this._catalogListRepair.clear(); + void Promise.allSettled([...repairSessions].map(session => this._markCatalogPayloadDirty(session))).then(() => { + if (!this._store.isDisposed) { + this._catalogReconciliationService.start(); } - if (m[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]) { - try { - const metadata = readAgentDevContainerWorktreeMetadata({ - [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: JSON.parse(m[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]), - }); - if (metadata) { - updated = { ...updated, _meta: withAgentDevContainerWorktreeMetadata(updated._meta, metadata.handle) }; - } - } catch (err) { - this._logService.warn(`[AgentService][listSessions] Failed to parse Dev Container worktree metadata for ${s.session}`, err); - } - } - if (m[META_GIT_STATE]) { - try { - const gitState = JSON.parse(m[META_GIT_STATE]) as ISessionGitState; - updated = { ...updated, _meta: withSessionGitState(updated._meta, gitState) }; - } catch (e) { - this._logService.warn(`[AgentService][listSessions] Failed to parse Git state for ${s.session}`, e); - } - } - if (m[META_GITHUB_STATE]) { - try { - const gitHubState = JSON.parse(m[META_GITHUB_STATE]) as ISessionGitHubState; - updated = { ...updated, _meta: withSessionGitHubState(updated._meta, gitHubState) }; - } catch (e) { - this._logService.warn(`[AgentService][listSessions] Failed to parse GitHub state for ${s.session}`, e); - } - } - if (m[META_SOURCE_CONTROL_STATE]) { - try { - const sourceControlState = parsePersistedSourceControlState(m[META_SOURCE_CONTROL_STATE]); - updated = { ...updated, _meta: withSessionSourceControlState(updated._meta, sourceControlState) }; - } catch (e) { - this._logService.warn(`[AgentService][listSessions] Failed to parse source-control state for ${s.session}`, e); - } - } - - if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { - updated = { ...updated, _meta: withSessionWorkspaceless(updated._meta, m[AH_META_WORKSPACELESS_DB_KEY] === 'true') }; - } - if (m[AH_META_EHCLI_ADOPTED_DB_KEY] !== undefined) { - updated = { ...updated, _meta: withSessionEhcliAdopted(updated._meta, m[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true') }; - } - if (m[AH_META_EHCLI_LAST_TURN_DB_KEY] !== undefined) { - updated = { ...updated, _meta: withSessionEhcliLastMigratedTurn(updated._meta, m[AH_META_EHCLI_LAST_TURN_DB_KEY]) }; - } - const multiRoot = parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY]); - if (multiRoot) { - updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) }; - } - const artifacts = this._readPersistedArtifacts(m[SESSION_ARTIFACTS_KEY], sessionStr, '[AgentService][listSessions]'); - if (artifacts.length > 0) { - updated = { ...updated, _meta: withSessionArtifacts(updated._meta, artifacts) }; - } - const folderPickerDecision = parseSessionFolderPickerDecision(m[SESSION_META_FOLDER_PICKER_KEY]); - if (folderPickerDecision) { - updated = { ...updated, _meta: withSessionFolderPickerDecision(updated._meta, folderPickerDecision) }; - } - - // Use the persisted root as-is to keep listing off Git; the metadata reader re-canonicalizes it on open. - const worktreeProject = worktreeProjectFromRepositoryRoot(m[WORKTREE_META_REPOSITORY_ROOT]); - if (worktreeProject) { - updated = { ...updated, project: worktreeProject }; - } - - return this._changesetCoordinator.decorateListEntry(updated, m as Record); - } finally { - ref.dispose(); - } - } catch (e) { - this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${s.session}`, e); - } - return sanitized; - }))); - const result = overlaid.filter((s): s is IAgentSessionMetadata => s !== undefined); - const overlayPhaseMs = Date.now() - overlayPhaseStartedAt; + }); + }, 0); + } + const result = results.filter((s): s is IAgentSessionMetadata => s !== undefined); // Overlay live session state from the state manager. // For the title, prefer the state manager's value when it is @@ -2322,10 +2877,13 @@ export class AgentService extends Disposable implements IAgentService { // `notify/sessionSummaryChanged`. const withStatus = result.map(s => { const liveSummary = this._stateManager.getSessionSummary(s.session.toString()); - if (liveSummary) { - return this._withLiveSessionMetadata(s, liveSummary); - } - return s; + const metadata = liveSummary + ? this._withLiveSessionMetadata(s, liveSummary, false, !this._stateManager.getSurfacedSessionSummary(s.session.toString())) + : s; + const persistedTitle = persistedFallbackTitles.get(s.session.toString()); + return persistedTitle && !this._stateManager.getSessionState(s.session.toString()) + ? { ...metadata, summary: persistedTitle } + : metadata; }); // Overlay any session known to state but missing from the providers' @@ -2355,6 +2913,9 @@ export class AgentService extends Disposable implements IAgentService { } const summaryWorkingDirs = summary.workingDirectories; + const summaryMeta = this._stateManager.getSurfacedSessionSummary(summary.resource) + ? withSessionMultiRootMetadata(summary._meta, undefined) + : summary._meta; additions.push({ session: URI.parse(summary.resource), startTime: Date.parse(summary.createdAt), @@ -2371,7 +2932,7 @@ export class AgentService extends Disposable implements IAgentService { // (e.g. the GitHub state published when a PR is created), so a // freshly-created session that the provider transiently omits // still reports it here. - ...(summary._meta !== undefined ? { _meta: summary._meta } : {}), + ...(summaryMeta !== undefined ? { _meta: summaryMeta } : {}), }); } const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus; @@ -2393,17 +2954,43 @@ export class AgentService extends Disposable implements IAgentService { const total = combined.length + hiddenExternal.size; this._logHiddenSessions(hiddenByExternalMode, total, mode); - // A catalog pass opens every registered session's database, so it can be slow. + // Legacy rows and per-session fallbacks can open session databases, so listing can still be slow. const duration = Date.now() - startedAt; - const message = `[AgentService] listSessions computed ${visible.length} of ${total} session(s) for mode '${mode}' in ${duration}ms (metadata ${metadataPhaseMs}ms, overlay ${overlayPhaseMs}ms, ${additions.length} state-manager fallback)`; + const message = `[AgentService] listSessions computed ${visible.length} of ${total} session(s) for mode '${mode}' in ${duration}ms (${additions.length} state-manager fallback)`; if (duration >= SLOW_LIST_SESSIONS_THRESHOLD_MS) { this._logService.info(message); } else { this._logService.trace(message); } + if (epoch !== this._registryEpoch) { + const currentRegistered = await this._listRegisteredSessions(); + if (!this._sameSessionRegistrations(allRegistered, currentRegistered)) { + const refreshEpoch = this._registryEpoch; + const refreshed = await this._computeSessions(mode, refreshEpoch); + const inFlight = this._inFlightListSessions.get(mode); + if (inFlight?.epoch === epoch) { + inFlight.epoch = refreshEpoch; + } + return refreshed; + } + } return visible; } + private _sameSessionRegistrations(first: readonly IRegisteredSession[], second: readonly IRegisteredSession[]): boolean { + if (first.length !== second.length) { + return false; + } + const secondBySession = new Map(second.map(session => [session.session.toString(), session])); + return first.every(session => { + const candidate = secondBySession.get(session.session.toString()); + return candidate?.provider === session.provider + && candidate.external === session.external + && candidate.source === session.source + && candidate.modifiedTime === session.modifiedTime; + }); + } + /** Last `hidden/total/mode` triple reported by {@link _logHiddenSessions}, so a steady state is logged once instead of on every refresh. */ private _lastHiddenSessionsLog: string | undefined; @@ -2433,9 +3020,17 @@ export class AgentService extends Disposable implements IAgentService { return this._configurationService.getRootValue(platformRootSchema, AgentHostShowExternalSessionsConfigKey) ?? AgentHostExternalSessionsMode.None; } + private readonly _startedChatDiscoveryProviders = new Set(); + private _startChatDiscovery(provider: IAgent, reason: string): void { - void provider.startChatDiscovery?.().catch(error => - this._logService.warn(`[AgentService] Chat discovery for provider ${provider.id} failed after ${reason}`, error)); + if (!provider.startChatDiscovery || this._startedChatDiscoveryProviders.has(provider.id)) { + return; + } + this._startedChatDiscoveryProviders.add(provider.id); + void provider.startChatDiscovery().catch(error => { + this._startedChatDiscoveryProviders.delete(provider.id); + this._logService.warn(`[AgentService] Chat discovery for provider ${provider.id} failed after ${reason}`, error); + }); } private _isExternalSessionOlderThanMaxAge(modifiedTime: number, now: number): boolean { @@ -2591,6 +3186,7 @@ export class AgentService extends Disposable implements IAgentService { /** Coalescing state for storm-driven (mode-agnostic) reconciliations. */ private _reconciliationInFlight = false; private _reconciliationDirty = false; + private _reconciliationForceCatalogRefresh = false; private _migrateLegacyEnabledSnapshot: boolean | undefined; @@ -2617,12 +3213,10 @@ export class AgentService extends Disposable implements IAgentService { return this._configurationService.getRootValue(agentMergeRootConfigSchema, AgentMergeConfigKey.Enabled) === true; } - private _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode): void { - // A mode change carries specific previous-mode state and is rare/user-driven, - // so run it directly rather than collapsing it into a coalesced pass. + private _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode, forceCatalogRefresh = previousMode !== undefined): void { if (previousMode !== undefined) { this._sessionListReconciliation = this._sessionListReconciliation - .then(() => this._reconcileExternalSessions(previousMode)) + .then(() => this._runSessionListReconciliation(previousMode, forceCatalogRefresh)) .catch(error => this._logService.warn('[AgentService] External session reconciliation failed', error)); return; } @@ -2632,24 +3226,41 @@ export class AgentService extends Disposable implements IAgentService { // one is in flight, further requests just mark it dirty to re-run once after. if (this._reconciliationInFlight) { this._reconciliationDirty = true; + this._reconciliationForceCatalogRefresh ||= forceCatalogRefresh; return; } this._reconciliationInFlight = true; this._reconciliationDirty = false; + const runForceCatalogRefresh = forceCatalogRefresh; this._sessionListReconciliation = this._sessionListReconciliation - .then(() => this._reconcileExternalSessions()) + .then(() => this._runSessionListReconciliation(undefined, runForceCatalogRefresh)) .catch(error => this._logService.warn('[AgentService] External session reconciliation failed', error)) .finally(() => { this._reconciliationInFlight = false; if (this._reconciliationDirty) { + const trailingForceCatalogRefresh = this._reconciliationForceCatalogRefresh; this._reconciliationDirty = false; - this._queueSessionListReconciliation(); + this._reconciliationForceCatalogRefresh = false; + this._queueSessionListReconciliation(undefined, trailingForceCatalogRefresh); } }); } - private async _reconcileExternalSessions(previousMode?: AgentHostExternalSessionsMode): Promise { + private async _runSessionListReconciliation(previousMode: AgentHostExternalSessionsMode | undefined, forceCatalogRefresh: boolean): Promise { + await this._reconcileExternalSessions(previousMode, forceCatalogRefresh); + } + + private async _reconcileExternalSessions(previousMode: AgentHostExternalSessionsMode | undefined, forceCatalogRefresh = false): Promise { const startedAt = Date.now(); + if (this._getExternalSessionsMode() !== AgentHostExternalSessionsMode.None) { + try { + await (forceCatalogRefresh + ? this._catalogReconciliationService.runFullPass() + : this._catalogReconciliationService.runPass()); + } catch (error) { + this._logService.warn('[AgentService] Catalog verification before external session reconciliation failed; continuing with cached rows', error); + } + } const previouslyBroadcast = new Set(this._broadcastExternalSessions); const previouslyExposed = new Set(previouslyBroadcast); for (const session of this._stateManager.getExposedExternalSessionKeys()) { @@ -2746,13 +3357,15 @@ export class AgentService extends Disposable implements IAgentService { this._announcedSurfacedKeys.delete(key); return; } + const title = await this._resolveSurfacedSessionTitle(meta); + const effectiveMetadata = title ? { ...meta, summary: title } : meta; // The external-sessions mode may have changed during the await above; re-check so a row that is no longer visible is not surfaced. - if (!this._shouldIncludeSession(meta)) { + if (!this._shouldIncludeSession(effectiveMetadata)) { this._announcedSurfacedKeys.delete(key); return; } - this._stateManager.announceSurfacedSession(this._surfacedSessionSummary(meta, provider)); - if (readSessionExternal(meta._meta)) { + this._stateManager.announceSurfacedSession(this._surfacedSessionSummary(effectiveMetadata, provider)); + if (readSessionExternal(effectiveMetadata._meta)) { this._broadcastExternalSessions.add(key); } } catch (err) { @@ -2761,6 +3374,61 @@ export class AgentService extends Disposable implements IAgentService { } } + private async _resolveSurfacedSessionTitle(metadata: IAgentSessionMetadata): Promise { + const registered = await this._sessionRegistry.get(metadata.session); + if (registered) { + const central = await this._catalogListReader.read(registered); + if (central.eligible) { + return central.metadata.summary; + } + } + return this._readPersistedSessionTitle(metadata.session); + } + + private async _readPersistedSessionTitle(session: URI): Promise { + const defaultChat = buildDefaultChatUri(session); + const defaultChatTitleKey = customChatTitleMetadataKey(defaultChat); + let mirroredChatTitle: string | undefined; + try { + const sessionRef = await this._sessionDataService.tryOpenDatabase(session); + if (sessionRef) { + try { + const metadata = await sessionRef.object.getMetadataObject({ + [SESSION_CUSTOM_TITLE_KEY]: true, + [defaultChatTitleKey]: true, + }); + const sessionTitle = metadata[SESSION_CUSTOM_TITLE_KEY]; + mirroredChatTitle = metadata[defaultChatTitleKey]; + if (sessionTitle) { + return sessionTitle; + } + } finally { + sessionRef.dispose(); + } + } + } catch (error) { + this._logService.warn(`[AgentService] Failed to read session title metadata for ${session.toString()}`, error); + } + return this._readDefaultChatTitle(session, mirroredChatTitle); + } + + private async _readDefaultChatTitle(session: URI, fallback?: string): Promise { + try { + const ref = await this._sessionDataService.tryOpenDatabase(URI.parse(buildDefaultChatUri(session))); + if (!ref) { + return fallback; + } + try { + return await ref.object.getMetadata(SESSION_CUSTOM_TITLE_KEY) || fallback; + } finally { + ref.dispose(); + } + } catch (error) { + this._logService.warn(`[AgentService] Failed to read default chat title for ${session.toString()}`, error); + return fallback; + } + } + /** Synthesizes the minimal {@link SessionSummary} for a provider session surfaced outside the normal list response. */ private _surfacedSessionSummary(meta: IAgentSessionMetadata, provider: string): SessionSummary { return { @@ -3005,12 +3673,9 @@ export class AgentService extends Disposable implements IAgentService { this._syncAgentMergeIndex(session, undefined, sessionConfig); this._serverToolHost.advertise(session.toString()); // Persist resolved config values for restore. Mid-session updates are - // persisted by `SessionFlagsContribution` on `SessionConfigChanged`. + // persisted by `AgentSideEffects` on `SessionConfigChanged`. if (sessionConfig?.values && Object.keys(sessionConfig.values).length > 0 && !created.provisional) { - const persistedConfigValues = omitTransientSessionConfigValues(sessionConfig.values); - if (Object.keys(persistedConfigValues).length > 0) { - this._persistConfigValues(session, persistedConfigValues); - } + this._persistConfigValues(session, sessionConfig.values); } this._changesetCoordinator.onSessionCreated(session.toString()); @@ -3018,9 +3683,13 @@ export class AgentService extends Disposable implements IAgentService { if (!created.provisional) { // Persist the host-owned workspace-less marker once the session DB // exists; provisional sessions defer this to `_onDidMaterializeChat`. - this._persistWorkspaceless(session, readSessionWorkspaceless(this._stateManager.getSessionSummary(session.toString())?._meta)); - this._persistMultiRoot(session, readSessionMultiRootMetadata(this._stateManager.getSessionSummary(session.toString())?._meta)); - this._persistFolderPickerDecision(session, readSessionFolderPickerDecision(this._stateManager.getSessionSummary(session.toString())?._meta)); + try { + await this._persistOrderedListVisibleSessionState(session, this._creationMetadataOverrides(this._stateManager.getSessionState(session.toString())?._meta)); + } catch (error) { + this._logService.warn(`[AgentService] Initial catalog synchronization for ${session.toString()} failed after creation; scheduling repair`, error); + await this._markCatalogPayloadDirty(session.toString()); + this._catalogReconciliationService.schedule(); + } // `SessionReady` means the agent has a live SDK session. Provisional // sessions defer it to {@link _onDidMaterializeChat}. @@ -3178,27 +3847,82 @@ export class AgentService extends Disposable implements IAgentService { } } - // Create the backing chat before publishing `session/chatAdded` so - // subscribers only see a chat that can already receive messages. - const createResult = await this._createChat(provider, chat, session, createOptions); - const providerData = createResult?.providerData; - try { - await this._persistPeerChat(session, chat, providerData, peerChatOrigin, createResult?.inheritedTurnId); - } catch (error) { - try { + const createResult = await this._chatCatalogMutationSequencer.queue(sessionKey, async () => { + // Create the backing chat before publishing `session/chatAdded` so + // subscribers only see a chat that can already receive messages. + const createResult = await this._createChat(provider, chat, session, createOptions); + const providerData = createResult?.providerData; + const title = forkedTitle ?? options?.title; + const sessionState = this._stateManager.getSessionState(sessionKey); + if (!sessionState) { await provider.chats.disposeChat(chat, this._chatContext(session, chat)); - } catch (rollbackError) { - throw new AggregateError([error, rollbackError], `Failed to persist and roll back chat ${chat.toString()}`); + throw new Error(`[AgentService] createChat: session state disappeared for ${sessionKey}`); + } + const existingCatalogChats = this._catalogChatsFromState(sessionState).map(existing => ( + existing.kind === 'default' && !existing.title && sessionState.title + ? { ...existing, title: sessionState.title } + : existing + )); + const newCatalogChat = { + uri: chat.toString(), + kind: 'peer' as const, + ...(title !== undefined ? { title } : {}), + ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), + }; + const existingIndex = existingCatalogChats.findIndex(existing => existing.uri === newCatalogChat.uri); + const catalogChats = existingIndex < 0 + ? [...existingCatalogChats, newCatalogChat] + : existingCatalogChats.map((existing, index) => index === existingIndex + ? { ...existing, ...newCatalogChat, kind: existing.kind } + : existing); + this._catalogSyncSuppressedSessions.add(sessionKey); + try { + await this._peerChatStore.upsert(session, chat, providerData, peerChatOrigin, createResult?.inheritedTurnId); + if (title !== undefined) { + await persistSessionMetadataValues(this._sessionDataService, chat.toString(), { + [SESSION_CUSTOM_TITLE_KEY]: title, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AUTO, + }); + } + await this._persistOrderedListVisibleSessionState(session, title === undefined ? {} : { + [customChatTitleMetadataKey(chat.toString())]: title, + [customChatTitleSourceMetadataKey(chat.toString())]: AGENT_HOST_TITLE_SOURCE_AUTO, + }, catalogChats); + this._stateManager.addChat(sessionKey, chat.toString(), { + ...(forkedTitle !== undefined ? { title: forkedTitle } : options?.title !== undefined ? { title: options.title } : {}), + ...(forkedTurns !== undefined ? { turns: forkedTurns } : {}), + ...(providerData !== undefined ? { providerData } : {}), + ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), + ...(createResult?.inheritedTurnId !== undefined ? { inheritedTurnId: createResult.inheritedTurnId } : {}), + }); + } catch (error) { + const rollbackErrors: Error[] = []; + if (existingIndex < 0) { + try { + await this._peerChatStore.remove(session, chat); + } catch (rollbackError) { + rollbackErrors.push(rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError))); + } + try { + await provider.chats.disposeChat(chat, this._chatContext(session, chat)); + } catch (rollbackError) { + rollbackErrors.push(rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError))); + } + try { + await this._sessionDataService.deleteSessionData(chat); + } catch (rollbackError) { + rollbackErrors.push(rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError))); + } + } + if (rollbackErrors.length > 0) { + throw new AggregateError([error, ...rollbackErrors], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(error)}`); + } + throw error; + } finally { + this._catalogSyncSuppressedSessions.delete(sessionKey); + this._flushDeferredCatalogMetadataOverrides(session); } - throw error; - } - - this._stateManager.addChat(sessionKey, chat.toString(), { - ...(forkedTitle !== undefined ? { title: forkedTitle } : options?.title !== undefined ? { title: options.title } : {}), - ...(forkedTurns !== undefined ? { turns: forkedTurns } : {}), - ...(providerData !== undefined ? { providerData } : {}), - ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), - ...(createResult?.inheritedTurnId !== undefined ? { inheritedTurnId: createResult.inheritedTurnId } : {}), + return createResult; }); this._sessionResidency.touch(session); void this._sessionResidency.reconcile(); @@ -3289,20 +4013,78 @@ export class AgentService extends Disposable implements IAgentService { const provider = this._providerService.getProviderForSession(session); this._disposingPeerChats.add(chatKey); try { - await this._checkpointService.discardChatTurnStartCheckpoints(session, chat); - if (provider) { - await this._disposeChat(provider, chat); - } - await this._removePersistedPeerChat(session, chat); - this._sideEffects.cancelSubagentSessions(chatKey); - this._sideEffects.clearChannelTelemetry(chatKey); - this._chatContributions.disposeChatState(chatKey); - this._stateManager.removeChat(sessionKey, chatKey); + await this._chatCatalogMutationSequencer.queue(sessionKey, async () => { + this._catalogSyncSuppressedSessions.add(sessionKey); + let membershipRemoved = false; + let ancillaryCleanupSucceeded = false; + try { + await this._checkpointService.discardChatTurnStartCheckpoints(session, chat); + if (provider) { + await this._disposeChat(provider, chat); + } + await this._peerChatStore.remove(session, chat); + membershipRemoved = true; + await this._clearChatDraft(session, chat); + await this._sessionDataService.deleteSessionData(chat); + const state = this._stateManager.getSessionState(sessionKey); + if (state) { + await this._persistOrderedListVisibleSessionState( + session, + { + [customChatTitleMetadataKey(chatKey)]: '', + [customChatTitleSourceMetadataKey(chatKey)]: '', + }, + this._catalogChatsFromState(state).filter(candidate => candidate.uri !== chatKey), + ); + } + ancillaryCleanupSucceeded = true; + } finally { + if (membershipRemoved) { + this._sideEffects.cancelSubagentSessions(chatKey); + this._sideEffects.clearChannelTelemetry(chatKey); + this._chatContributions.disposeChatState(chatKey); + this._stateManager.removeChat(sessionKey, chatKey); + await this._markCatalogPayloadDirty(sessionKey); + this._catalogReconciliationService.schedule(); + if (!ancillaryCleanupSucceeded) { + this._schedulePeerChatCleanupRepair(session, chat); + } + } + this._catalogSyncSuppressedSessions.delete(sessionKey); + this._flushDeferredCatalogMetadataOverrides(session); + } + }); } finally { this._disposingPeerChats.delete(chatKey); } } + private _schedulePeerChatCleanupRepair(session: URI, chat: URI): void { + const chatKey = chat.toString(); + this._peerChatCleanupRepairs.set(chatKey, disposableTimeout(() => { + this._peerChatCleanupRepairs.deleteAndDispose(chatKey); + void (async () => { + try { + await this._clearChatDraft(session, chat); + await this._sessionDataService.deleteSessionData(chat); + const state = this._stateManager.getSessionState(session.toString()); + if (state) { + await this._persistOrderedListVisibleSessionState( + session, + { + [customChatTitleMetadataKey(chatKey)]: '', + [customChatTitleSourceMetadataKey(chatKey)]: '', + }, + this._catalogChatsFromState(state).filter(candidate => candidate.uri !== chatKey), + ); + } + } catch (error) { + this._logService.warn(`[AgentService] Failed to repair ancillary state for removed chat ${chatKey}`, error); + } + })(); + }, 1000)); + } + // ---- Chat dispatch adapter --------------------------------------------- // // The orchestrator owns the feature-level `(session, chat)` → @@ -3397,7 +4179,7 @@ export class AgentService extends Disposable implements IAgentService { if (state) { return this._getSessionChatsInTeardownOrder(session); } - const persisted = await this._readPersistedPeerChatCatalog(session); + const persisted = await this._peerChatStore.tryRead(session); const peerChats = persisted?.map(chat => chat.uri) ?? (await provider.listLegacyChatBackings?.(session))?.map(chat => chat.uri.toString()) ?? []; @@ -3424,10 +4206,11 @@ export class AgentService extends Disposable implements IAgentService { * Destructively tears a session down: dispose peer chats first and the * default chat last, and still visit every chat if one rejects. */ - private async _disposeSession(provider: IAgent, session: URI): Promise { + private async _disposeSession(provider: IAgent, session: URI): Promise { await this._defaultChatBackingWrites.get(session.toString())?.catch(() => { }); let firstError: unknown; - for (const chat of await this._getSessionChatsForDisposal(provider, session)) { + const chats = await this._getSessionChatsForDisposal(provider, session); + for (const chat of chats) { try { await provider.chats.disposeChat(chat, this._chatContext(session, chat)); } catch (err) { @@ -3437,6 +4220,7 @@ export class AgentService extends Disposable implements IAgentService { if (firstError !== undefined) { throw firstError; } + return chats; } /** @@ -3741,16 +4525,11 @@ export class AgentService extends Disposable implements IAgentService { }; const configValues = state.config?.values; if (configValues && Object.keys(configValues).length > 0) { - const persistedConfigValues = omitTransientSessionConfigValues(configValues); - if (Object.keys(persistedConfigValues).length > 0) { - this._persistConfigValues(session, persistedConfigValues); - } + this._persistConfigValues(session, configValues); } // Persist the AH-owned workspace-less marker now that the session has a // real on-disk database (deferred from create for provisional sessions). - this._persistWorkspaceless(session, readSessionWorkspaceless(summary._meta)); - this._persistMultiRoot(session, readSessionMultiRootMetadata(summary._meta)); - this._persistFolderPickerDecision(session, readSessionFolderPickerDecision(summary._meta)); + this._queueCatalogSync(session, this._creationMetadataOverrides(state._meta)); // `markSessionPersisted` writes the summary into state and fires // the deferred `SessionAdded` notification atomically so subscribers // see consistent state through both paths. @@ -3817,62 +4596,19 @@ export class AgentService extends Disposable implements IAgentService { } } - private _persistWorkspaceless(session: URI, workspaceless: boolean): void { - let ref; - try { - ref = this._sessionDataService.openDatabase(session); - } catch (err) { - this._logService.warn(`[AgentService] Failed to open session database to persist workspaceless for ${session.toString()}: ${toErrorMessage(err)}`); - return; - } - ref.object.setMetadata(AH_META_WORKSPACELESS_DB_KEY, workspaceless ? 'true' : 'false').catch(err => { - this._logService.warn(`[AgentService] Failed to persist workspaceless for ${session.toString()}: ${toErrorMessage(err)}`); - }).finally(() => { - ref.dispose(); - }); - } - - private _persistMultiRoot(session: URI, multiRoot: ReturnType): void { - if (!multiRoot) { - return; - } - let ref; - try { - ref = this._sessionDataService.openDatabase(session); - } catch (err) { - this._logService.warn(`[AgentService] Failed to open session database to persist multi-root metadata for ${session.toString()}: ${toErrorMessage(err)}`); - return; - } - ref.object.setMetadata(SESSION_META_MULTI_ROOT_KEY, JSON.stringify(multiRoot)).catch(err => { - this._logService.warn(`[AgentService] Failed to persist multi-root metadata for ${session.toString()}: ${toErrorMessage(err)}`); - }).finally(() => { - ref.dispose(); - }); - } - - /** - * Persists the harness-owned Folder-picker decision so it survives reload as - * a frozen creation-time fact: a session created with the picker hidden stays - * hidden on reopen, and one created with it shown stays shown. Deferred to - * {@link _onDidMaterializeChat} for provisional sessions (no DB yet at - * create), mirroring {@link _persistMultiRoot}. - */ - private _persistFolderPickerDecision(session: URI, decision: ReturnType): void { - if (!decision) { - return; + private _creationMetadataOverrides(meta: SessionSummary['_meta']): Readonly> { + const overrides: Record = { + [AH_META_WORKSPACELESS_DB_KEY]: readSessionWorkspaceless(meta) ? 'true' : 'false', + }; + const multiRoot = readSessionMultiRootMetadata(meta); + if (multiRoot) { + overrides[SESSION_META_MULTI_ROOT_KEY] = JSON.stringify(multiRoot); } - let ref; - try { - ref = this._sessionDataService.openDatabase(session); - } catch (err) { - this._logService.warn(`[AgentService] Failed to open session database to persist folder-picker decision for ${session.toString()}: ${toErrorMessage(err)}`); - return; + const folderPicker = readSessionFolderPickerDecision(meta); + if (folderPicker) { + overrides[SESSION_META_FOLDER_PICKER_KEY] = JSON.stringify(folderPicker); } - ref.object.setMetadata(SESSION_META_FOLDER_PICKER_KEY, JSON.stringify(decision)).catch(err => { - this._logService.warn(`[AgentService] Failed to persist folder-picker decision for ${session.toString()}: ${toErrorMessage(err)}`); - }).finally(() => { - ref.dispose(); - }); + return overrides; } private _persistConfigValues(session: URI, values: Record): void { @@ -3883,7 +4619,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.warn(`[AgentService] Failed to open session database to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`); return; } - ref.object.setMetadata('configValues', JSON.stringify(values)).catch(err => { + ref.object.setMetadata('configValues', JSON.stringify(omitTransientSessionConfigValues(values))).catch(err => { this._logService.warn(`[AgentService] Failed to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`); }).finally(() => { ref.dispose(); @@ -4060,63 +4796,95 @@ export class AgentService extends Disposable implements IAgentService { private async _doDisposeSession(session: URI): Promise { const sessionKey = session.toString(); - this._cancelPendingSessionGc(session); - const isEphemeral = this._stateManager.isEphemeralSession(sessionKey); - const isIdleProvisional = this._stateManager.isIdleProvisionalSession(sessionKey); - this._stateManager.invalidateSessionChatResolutions(session.toString()); - const sessionChats = this._stateManager.getSessionState(session.toString())?.chats ?? []; - for (const chat of sessionChats) { - this._sideEffects.clearChannelTelemetry(chat.resource); - } - this._sideEffects.clearChannelTelemetry(session.toString()); - // Resolve the working directories up front and pass them explicitly: - // the checkpoint and review services need them to locate the - // repositories holding this session's refs, and reading them from - // session state would silently break the moment `deleteSession` below - // is reordered ahead of the data deletion. - const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session.toString()); - const sessionId = AgentSession.id(session); - const worktree = await this._worktree.prepareSessionDeletion(session, sessionId); - const provider = this._providerService.getProviderForSession(session); - if (provider) { - await this._disposeSession(provider, session); - } - if (!isEphemeral) { - await this._retryRegistryMutation( - () => this._sessionRegistry.tombstone(session), - `unregistration for ${session.toString()}`, - ); - } - if (!isIdleProvisional) { - this._invalidateSessionList(); + const catalogDeletionFence = this._catalogSyncService.beginSessionDeletion(session); + let peerChatDeletionBegun = false; + try { + this._cancelPendingSessionGc(session); + const isEphemeral = this._stateManager.isEphemeralSession(sessionKey); + const isIdleProvisional = this._stateManager.isIdleProvisionalSession(sessionKey); + this._stateManager.invalidateSessionChatResolutions(session.toString()); + const sessionChats = this._stateManager.getSessionState(session.toString())?.chats ?? []; + for (const chat of sessionChats) { + this._sideEffects.clearChannelTelemetry(chat.resource); + } + this._sideEffects.clearChannelTelemetry(session.toString()); + // Resolve the working directories up front and pass them explicitly: + // the checkpoint and review services need them to locate the + // repositories holding this session's refs, and reading them from + // session state would silently break the moment `deleteSession` below + // is reordered ahead of the data deletion. + const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session.toString()); + const sessionId = AgentSession.id(session); + const persistedPeerChats = sessionChats.length === 0 ? await this._peerChatStore.tryRead(session) : undefined; + const worktree = await this._worktree.prepareSessionDeletion(session, sessionId); + await this._peerChatStore.beginSessionDeletion(session); + peerChatDeletionBegun = true; + const provider = this._providerService.getProviderForSession(session); + let chatsToDelete = this._orderSessionChatsForTeardown(session, [ + ...sessionChats.map(chat => chat.resource), + ...(persistedPeerChats?.map(chat => chat.uri) ?? []), + ]); + if (provider) { + chatsToDelete = [...await this._disposeSession(provider, session)]; + } + await this._whenBackgroundCatalogStateWritesIdle(sessionKey); + await catalogDeletionFence.whenDrained; + if (!isEphemeral) { + await this._retryRegistryMutation( + () => this._sessionRegistry.tombstone(session), + `unregistration for ${session.toString()}`, + ); + } + if (!isIdleProvisional) { + this._invalidateSessionList(); + } + if (provider) { + this._providerService.releaseSession(session.toString()); + this._clearDownloadProgressInterest(session.toString()); + } + this._sideEffects.clearSessionTitleState(session.toString(), sessionChats.map(chat => chat.resource)); + this._chatContributions.disposeSessionState(session.toString()); + await this._whenSessionDataIdle(session); + for (const chat of chatsToDelete) { + await this._sessionDataService.deleteSessionData(chat); + } + // Remove the VS Code per-session data directory (metadata DB + checkpoints) to mirror the SDK-side cleanup + // performed by the provider above. No-op when the directory does not exist. + // + // Runs before the worktree is removed: subscribers of the will-delete + // event drop this session's git refs, and for a worktree-isolated + // session the working directory *is* the worktree, so once it is gone + // the repository can no longer be resolved and the refs would leak + // into the main repository (`refs/agents/*` is shared, not per-worktree). + await this._sessionDataService.deleteSessionData(session, workingDirectories); + await this._worktree.removeSessionWorktree(sessionId, worktree); + this._changesetCoordinator.onSessionDisposed(session.toString()); + this._sideEffects.clearInputRequestsForSession(session.toString()); + // Remove all subagent sessions for this parent + this._sideEffects.removeSubagentSessions(session.toString()); + this._stateManager.deleteSession(session.toString()); + this._externalReconciliationModifiedAt.delete(sessionKey); + if (isEphemeral) { + await this._retryRegistryMutation( + () => this._sessionRegistry.clearTombstone(session), + `clearing ephemeral session tombstone for ${session.toString()}`, + ); + } + } finally { + if (peerChatDeletionBegun) { + this._peerChatStore.endSessionDeletion(session); + } + catalogDeletionFence.dispose(); } - if (provider) { - this._providerService.releaseSession(session.toString()); - this._clearDownloadProgressInterest(session.toString()); - } - this._sideEffects.clearSessionTitleState(session.toString(), sessionChats.map(chat => chat.resource)); - this._chatContributions.disposeSessionState(session.toString()); - await this._whenSessionDataIdle(session); - // Remove the VS Code per-session data directory (metadata DB + checkpoints) to mirror the SDK-side cleanup - // performed by the provider above. No-op when the directory does not exist. - // - // Runs before the worktree is removed: subscribers of the will-delete - // event drop this session's git refs, and for a worktree-isolated - // session the working directory *is* the worktree, so once it is gone - // the repository can no longer be resolved and the refs would leak - // into the main repository (`refs/agents/*` is shared, not per-worktree). - await this._sessionDataService.deleteSessionData(session, workingDirectories); - await this._worktree.removeSessionWorktree(sessionId, worktree); - this._changesetCoordinator.onSessionDisposed(session.toString()); - this._sideEffects.clearInputRequestsForSession(session.toString()); - // Remove all subagent sessions for this parent - this._sideEffects.removeSubagentSessions(session.toString()); - this._stateManager.deleteSession(session.toString()); - if (isEphemeral) { - await this._retryRegistryMutation( - () => this._sessionRegistry.clearTombstone(session), - `clearing ephemeral session tombstone for ${session.toString()}`, - ); + } + + private async _whenBackgroundCatalogStateWritesIdle(sessionKey: string): Promise { + while (true) { + const writes = this._backgroundCatalogStateWrites.get(sessionKey); + if (!writes || writes.size === 0) { + return; + } + await Promise.allSettled([...writes]); } } @@ -4524,14 +5292,68 @@ export class AgentService extends Disposable implements IAgentService { if (!this._stateManager.getSurfacedSessionSummary(session)) { return false; } + const sessionUri = URI.parse(session); const [key, flag, set] = action.type === ActionType.SessionIsArchivedChanged ? [AH_META_IS_ARCHIVED_DB_KEY, SessionStatus.IsArchived, action.isArchived] as const : [AH_META_IS_READ_DB_KEY, SessionStatus.IsRead, action.isRead] as const; await persistSessionMetadataValues(this._sessionDataService, session, { [key]: set ? 'true' : '' }); + try { + await this._synchronizePassiveSessionMetadata(sessionUri, key, flag, set); + } catch (error) { + this._logService.warn(`[AgentService] Failed to synchronize passive session metadata for ${session}`, error); + } + await this._markCatalogPayloadDirty(session); + this._catalogReconciliationService.schedule(); + this._invalidateSessionList(); this._stateManager.setSurfacedSessionStatusFlag(session, flag, set); return true; } + private async _synchronizePassiveSessionMetadata(session: URI, key: string, flag: SessionStatus, set: boolean): Promise { + let requestUnavailable = false; + try { + const result = await this._catalogSyncService.synchronizeWithFactory(session, async database => { + const sessionKey = session.toString(); + const catalog = await this._orchestratorDatabase.getSessionV2(sessionKey); + let request: IAgentHostCatalogSyncRequest | undefined; + if (catalog) { + const decoded = decodeAgentHostCatalogPayload(catalog.payload); + if (decoded.ok) { + request = { + data: { + ...decoded.value.data, + ...(flag === SessionStatus.IsArchived ? { isArchived: set } : { isRead: set }), + }, + legacyMetadata: { [key]: set ? 'true' : '' }, + }; + } + } + if (!request) { + const registered = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); + if (registered) { + const source = await this._resolveCatalogReconciliationSource(registered, database); + if (source.status === 'available') { + request = source.request; + } + } + } + if (!request) { + requestUnavailable = true; + throw new Error(`No catalog synchronization source is available for passive session metadata ${sessionKey}`); + } + return request; + }); + if (result.status === 'pending') { + this._logService.warn(`[AgentService] Catalog synchronization for passive session metadata ${session.toString()} remains pending: ${result.reason}`); + } + } catch (error) { + if (requestUnavailable) { + return; + } + throw error; + } + } + private _isAutomationAction(action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction): action is ClientAutomationAction { return action.type === ActionType.AutomationCreateRequested || action.type === ActionType.AutomationUpdateRequested @@ -5167,6 +5989,13 @@ export class AgentService extends Disposable implements IAgentService { if (await this._sessionRegistry.isTombstoned(session)) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); } + const unresolvedRegistration = await this._orchestratorDatabase.getSessionV2Registration(sessionStr); + if (unresolvedRegistration && unresolvedRegistration.external === undefined) { + const migrationProvider = this._providerService.getProvider(unresolvedRegistration.provider); + if (migrationProvider) { + await this._awaitInitialProviderMigrationForProvider(migrationProvider); + } + } let registeredSession = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); if (registeredSession) { this._providerService.associateSession(session, registeredSession.provider); @@ -5270,7 +6099,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.warn(`[AgentService] Failed to surface adopted session ${sessionStr} before restore`, err); } } - const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', awaitCatalogReadable, !!registeredSession, adoption.worktree); + const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', awaitCatalogReadable, !!registeredSession, adoption.worktree, adoption.listVisible); await this._restoreAnnotations(session); if (adopted) { // Discovery never surfaced this chat when migration was enabled after @@ -5376,7 +6205,10 @@ export class AgentService extends Disposable implements IAgentService { * Returns the facts used for migration telemetry; throws if any required step * fails so the caller can report the outcome accurately. */ - private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], awaitCatalogReadable: () => Promise, sessionKnownToRegistry: boolean, adoptionWorktree: IAgentAdoptedWorktree | undefined): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { + private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], awaitCatalogReadable: () => Promise, sessionKnownToRegistry: boolean, adoptionWorktree: IAgentAdoptedWorktree | undefined, adoptionListVisible: IAgentChatAdoptionResult['listVisible']): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { + if ((adoptionListVisible?.title === undefined) !== (adoptionListVisible?.titleSource === undefined)) { + throw new Error(`Adoption title and source must be provided together for ${sessionStr}`); + } this._logService.trace(`[AgentService] restore: reading provider metadata for ${sessionStr}`); let meta = await this._getSessionMetadataForRestore(agent, session, external); if (!meta) { @@ -5491,7 +6323,7 @@ export class AgentService extends Disposable implements IAgentService { let title = meta.summary ?? 'Session'; let isRead: boolean | undefined; let isArchived: boolean | undefined; - let persistedConfigValues: Record | undefined; + let persistedConfigValues: Record | undefined; let changes: ChangesSummary | undefined; let gitMetadata: Record | undefined; let changesetMetadata: Record | undefined; @@ -5601,7 +6433,12 @@ export class AgentService extends Disposable implements IAgentService { if (m.configValues) { try { - persistedConfigValues = omitTransientSessionConfigValues(JSON.parse(m.configValues)); + const parsed: unknown = JSON.parse(m.configValues); + if (isRecord(parsed)) { + persistedConfigValues = omitTransientSessionConfigValues(parsed); + } else { + this._logService.warn(`[AgentService] Ignoring persisted configValues with an invalid shape for ${sessionStr}`); + } } catch (err) { this._logService.warn(`[AgentService] Failed to parse persisted configValues for ${sessionStr}: ${toErrorMessage(err)}`); } @@ -5615,6 +6452,12 @@ export class AgentService extends Disposable implements IAgentService { } } this._logService.trace(`[AgentService] restore: persisted session metadata read for ${sessionStr}`); + if (adoptionListVisible?.title !== undefined) { + title = adoptionListVisible.title; + } + if (adoptionListVisible?.isRead !== undefined) { + isRead = adoptionListVisible.isRead; + } // Encode isRead/isArchived as status bitmask flags let status: SessionStatus = SessionStatus.Idle; @@ -5653,8 +6496,10 @@ export class AgentService extends Disposable implements IAgentService { ? { ...(defaultDraft ?? { text: '', origin: { kind: MessageKind.User } }), model: meta.model } : defaultDraft; const mergedTurns = await this._interleaveLocalTurns(sessionStr, defaultChatUri.toString(), turns); + const currentRegistration = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); + const effectiveRegistrationSource = currentRegistration?.source ?? registrationSource; const registered = await this._retryRegistryMutation( - () => this._sessionRegistry.register(session, { provider: agent.id, startTime: meta.startTime, modifiedTime: meta.modifiedTime, source: registrationSource }, { checkTombstone: true }), + () => this._sessionRegistry.register(session, { provider: agent.id, startTime: meta.startTime, modifiedTime: meta.modifiedTime, source: effectiveRegistrationSource }, { checkTombstone: true }), `registration for restored session ${session.toString()}`, ); if (!registered) { @@ -5666,6 +6511,17 @@ export class AgentService extends Disposable implements IAgentService { } this._invalidateSessionList(); this._stateManager.restoreSession(summary, mergedTurns, { draft: restoredDraft, defaultChatTitle }); + if (adoptionListVisible) { + const adoptionMetadata: Record = {}; + if (adoptionListVisible.title !== undefined) { + adoptionMetadata[SESSION_CUSTOM_TITLE_KEY] = adoptionListVisible.title; + adoptionMetadata[SESSION_CUSTOM_TITLE_SOURCE_KEY] = adoptionListVisible.titleSource; + } + if (adoptionListVisible.isRead !== undefined) { + adoptionMetadata[AH_META_IS_READ_DB_KEY] = adoptionListVisible.isRead ? 'true' : ''; + } + await this._persistListVisibleSessionState(session, adoptionMetadata); + } this._logService.trace(`[AgentService] restore: hydrated state for ${sessionStr} with ${mergedTurns.length} turn(s)`); this._serverToolHost.advertise(sessionStr); @@ -5754,59 +6610,113 @@ export class AgentService extends Disposable implements IAgentService { }; } - /** - * Restores the additional (non-default) peer chats for a session. - * - * Enumeration is driven by the orchestrator's OWN persisted catalog (the - * {@link PEER_CHATS_METADATA_KEY} blob). Each catalog entry is registered - * immediately with its persisted title, draft, origin, and provider data. - * Its backing and history remain unloaded until the peer chat is requested. - * - * When the orchestrator catalog is absent ({@link _readPersistedPeerChatCatalog} - * returns `undefined`) the session predates orchestrator-owned persistence: - * a one-time migration ({@link _migrateLegacyPeerChats}) drains the agent's - * legacy `*.chats` enumeration into the catalog so it is never consulted - * again. - */ + /** Restores authoritative central peer membership after importing cooling-period legacy changes. */ private async _restorePeerChats(agent: IAgent, session: URI): Promise { - const persisted = await this._readPersistedPeerChatCatalog(session); - if (persisted !== undefined) { - // The orchestrator owns the catalog: enumerate from it. - await this._restorePeerChatsFromCatalog(session, persisted); - return; + const cached = await this._readCachedChatCatalog(session); + let entries: readonly IPersistedPeerChat[]; + try { + entries = await this._readOrMigrateLegacyPeerChatCatalog(agent, session); + } catch (error) { + const cachedPeers = cached?.filter(chat => chat.kind === 'peer'); + if (!cachedPeers?.length) { + throw error; + } + this._logService.warn(`[AgentService] Restoring cached peer-chat membership without backing enrichment for ${session.toString()}`, error); + entries = await this._peerChatStore.readLocalChatMetadata(cachedPeers.map(chat => ({ + uri: chat.uri, + ...(chat.origin !== undefined ? { origin: chat.origin } : {}), + ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), + }))); } - // No orchestrator catalog yet: one-time migration from legacy `*.chats`. - await this._migrateLegacyPeerChats(agent, session); + await this._restorePeerChatsFromCatalog(session, entries, cached); + await this._persistOrderedListVisibleSessionState(session, {}); } - /** - * One-time migration for sessions persisted before the orchestrator owned - * the peer-chat catalog: enumerate the agent's legacy `*.chats` - * ({@link IAgent.listLegacyChatBackings}), register them via the same path as the - * new catalog, then write the orchestrator {@link PEER_CHATS_METADATA_KEY} - * blob so subsequent restores read the new catalog and never consult the - * legacy read again. No-op when the agent has no legacy enumeration or none - * is persisted. - */ - private async _migrateLegacyPeerChats(agent: IAgent, session: URI): Promise { - const legacy = await agent.listLegacyChatBackings?.(session); - if (!legacy || legacy.length === 0) { - // Write an empty catalog sentinel so `_readPersistedPeerChatCatalog` - // returns `[]` on subsequent restores and this migration never re-runs. - await this._enqueuePeerChatCatalogWrite(session, () => []); - return; + private async _readCentralChatCatalog(session: URI): Promise { + const peers = await this._peerChatStore.tryRead(session); + if (peers !== undefined) { + return [ + { uri: buildDefaultChatUri(session.toString()), kind: 'default' }, + ...peers.map(peer => ({ + uri: peer.uri, + kind: 'peer' as const, + origin: peer.origin, + inheritedTurnId: peer.inheritedTurnId, + })), + ]; + } + return this._readCachedChatCatalog(session); + } + + private async _readCachedChatCatalog(session: URI): Promise { + const registered = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); + if (!registered) { + return undefined; + } + const result = await this._catalogListReader.read(registered); + if (!result.eligible) { + if (result.chatBacking) { + this._logService.trace(`[AgentService] Central chat catalog for ${session.toString()} is a chat backing`); + } else if (result.error) { + this._logService.warn(`[AgentService] Failed to read central chat catalog for ${session.toString()}`, result.error); + } else { + this._logService.trace(`[AgentService] Central chat catalog for ${session.toString()} is ineligible: ${result.detail}`); + } + return undefined; + } + return result.data.chats.map(chat => ({ + uri: chat.uri.toString(), + kind: chat.kind, + title: chat.summary, + origin: fromCatalogChatOrigin(chat.origin), + inheritedTurnId: chat.inheritedTurnId, + })); + } + + private async _readOrMigrateLegacyPeerChatCatalog(agent: IAgent, session: URI, database?: AgentHostCatalogDatabaseReference): Promise { + const persisted = await this._peerChatStore.reconcileLegacy(session, database); + if (persisted !== undefined) { + return persisted; + } + const cached = await this._readCachedChatCatalog(session); + if (cached?.some(chat => chat.kind === 'peer')) { + const projectedPeers: IPersistedPeerChat[] = cached.filter(chat => chat.kind === 'peer').map(chat => ({ + uri: chat.uri, + ...(chat.origin !== undefined ? { origin: chat.origin } : {}), + ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), + })); + let legacy: readonly IAgentLegacyChat[] = []; + try { + legacy = await agent.listLegacyChatBackings?.(session) ?? []; + } catch (error) { + this._logService.warn(`[AgentService] Failed to enrich cached peer-chat membership for ${session.toString()}`, error); + throw error; + } + const legacyProviderData = new Map(legacy.map(chat => [chat.uri.toString(), chat.providerData])); + const enrichedPeers = projectedPeers.map(peer => { + const providerData = legacyProviderData.get(peer.uri); + return providerData !== undefined ? { ...peer, providerData } : peer; + }); + const peers = await this._peerChatStore.readLocalChatMetadata(enrichedPeers); + await this._peerChatStore.replace(session, peers); + return peers; + } + let legacy: readonly IAgentLegacyChat[] | undefined; + try { + legacy = await agent.listLegacyChatBackings?.(session); + } catch (error) { + this._logService.warn(`[AgentService] Failed to enumerate peer-chat membership for ${session.toString()}`, error); + throw error; + } + if (legacy === undefined) { + return []; } const entries: IPersistedPeerChat[] = legacy.map(chat => ({ uri: chat.uri.toString(), ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}), })); - await this._restorePeerChatsFromCatalog(session, entries); - // Single atomic write: the key is absent before and complete after, so no - // partial catalog can survive a crash mid-migration (which would make - // `_readPersistedPeerChatCatalog` return a proper subset and permanently - // skip re-migration). The callback takes no parameter so `entries` here is - // the full migrated set, not the (absent) current catalog. - await this._enqueuePeerChatCatalogWrite(session, () => [...entries]); + await this._peerChatStore.replace(session, entries); + return entries; } /** @@ -5814,7 +6724,7 @@ export class AgentService extends Disposable implements IAgentService { * Titles and drafts are metadata-only reads; backing sessions and histories * are loaded on the first content request. */ - private async _restorePeerChatsFromCatalog(session: URI, entries: readonly IPersistedPeerChat[]): Promise { + private async _restorePeerChatsFromCatalog(session: URI, entries: readonly IPersistedPeerChat[], cachedChats?: readonly ICatalogChat[]): Promise { const restored = await Promise.all(entries.map(async (entry) => { let chatUri: URI; try { @@ -5823,10 +6733,11 @@ export class AgentService extends Disposable implements IAgentService { this._logService.warn(`[AgentService] Skipping malformed persisted peer chat URI '${entry.uri}': ${toErrorMessage(err)}`); return undefined; } + const cachedTitle = cachedChats?.find(chat => chat.uri === entry.uri)?.title; const { title, draft } = await this._chatContributions.hydrateChat({ session: session.toString(), chat: chatUri.toString(), - }, {}); + }, cachedTitle ? { title: cachedTitle } : {}); return { chatUri, title, draft, providerData: entry.providerData, origin: entry.origin, inheritedTurnId: entry.inheritedTurnId }; })); for (const item of restored) { @@ -5858,19 +6769,24 @@ export class AgentService extends Disposable implements IAgentService { * does, with the same retry/suppression semantics, so a restored peer * chat's backing session cannot leak into the top-level session list. */ - private async _materializeRestoredPeerChat(session: URI, chat: URI, providerData: string | undefined): Promise<{ turns: Turn[] }> { + private async _materializeRestoredPeerChat(session: URI, chat: URI, providerData: string | undefined): Promise<{ turns: Turn[]; draft?: Message }> { const chatKey = chat.toString(); const agent = this._providerService.getProviderForSession(session); if (!agent) { throw new Error(`No agent provider for restored peer chat: ${chatKey}`); } try { - const result = await agent.materializeChat(chat, this._chatContext(session, chat), providerData); + const [persisted, draft] = await Promise.all([ + providerData === undefined ? this._peerChatStore.find(session, chat) : undefined, + this._getChatDraft(session, chat), + ]); + const effectiveProviderData = providerData ?? persisted?.providerData; + const result = await agent.materializeChat(chat, this._chatContext(session, chat), effectiveProviderData); if (result?.backingSession) { await this._markChatBacking(result.backingSession, chat); } const turns = await this._getChatMessages(agent, chat, session); - return { turns: await this._interleaveLocalTurns(session.toString(), chatKey, turns) }; + return { turns: await this._interleaveLocalTurns(session.toString(), chatKey, turns), draft }; } catch (err) { this._logService.warn(`[AgentService] Failed to materialize peer chat ${chatKey}: ${toErrorMessage(err)}`); throw err; @@ -5897,7 +6813,7 @@ export class AgentService extends Disposable implements IAgentService { return; } this._stateManager.updateChatProviderData(e.chat.toString(), e.providerData); - void this._persistPeerChat(URI.parse(sessionStr), e.chat, e.providerData) + void this._peerChatStore.upsert(URI.parse(sessionStr), e.chat, e.providerData) .catch(err => this._logService.error(err, `[AgentService] Failed to persist peer-chat backing for ${e.chat.toString()}`)); } @@ -6022,15 +6938,26 @@ export class AgentService extends Disposable implements IAgentService { const providerData = created.chat?.providerData; let providerDataError: Error | undefined; if (providerData !== undefined) { - const ref = this._sessionDataService.openDatabase(created.session); + const defaultChat = URI.parse(buildDefaultChatUri(created.session)); + const ref = this._sessionDataService.openDatabase(defaultChat); try { - await ref.object.setMetadata(DEFAULT_CHAT_PROVIDER_DATA_METADATA_KEY, providerData); + await ref.object.setMetadata(CHAT_PROVIDER_DATA_METADATA_KEY, providerData); } catch (err) { this._logService.warn(`[AgentService] failed to persist default-chat provider data for ${created.session.toString()}`, err); providerDataError = err instanceof Error ? err : new Error(String(err)); } finally { ref.dispose(); } + try { + const compatibilityRef = this._sessionDataService.openDatabase(created.session); + try { + await compatibilityRef.object.setMetadata(DEFAULT_CHAT_PROVIDER_DATA_METADATA_KEY, providerData); + } finally { + compatibilityRef.dispose(); + } + } catch (err) { + this._logService.warn(`[AgentService] failed to mirror default-chat provider data for ${created.session.toString()}`, err); + } } if (created.chat?.backingSession) { await this._markChatBacking(created.chat.backingSession, URI.parse(buildDefaultChatUri(created.session))); @@ -6041,52 +6968,24 @@ export class AgentService extends Disposable implements IAgentService { } private async _readDefaultChatProviderData(session: URI): Promise { - const ref = await this._sessionDataService.tryOpenDatabase?.(session); - if (!ref) { - return undefined; - } - try { - return await ref.object.getMetadata(DEFAULT_CHAT_PROVIDER_DATA_METADATA_KEY); - } finally { - ref.dispose(); + const defaultChat = URI.parse(buildDefaultChatUri(session)); + const chatRef = await this._sessionDataService.tryOpenDatabase?.(defaultChat); + if (chatRef) { + try { + const providerData = await chatRef.object.getMetadata(CHAT_PROVIDER_DATA_METADATA_KEY); + if (providerData !== undefined) { + return providerData || undefined; + } + } finally { + chatRef.dispose(); + } } - } - - /** - * Reads the orchestrator's persisted peer-chat catalog for a session. - * Returns `undefined` when the session has no catalog yet (a legacy session - * predating orchestrator-owned persistence, or a corrupt blob); the caller - * then performs a one-time migration from the agent's legacy `*.chats` - * enumeration (see {@link _restorePeerChats} / {@link _migrateLegacyPeerChats}). - * An empty array means the session is known to have no peer chats, so - * migration is skipped. - */ - private async _readPersistedPeerChatCatalog(session: URI): Promise { const ref = await this._sessionDataService.tryOpenDatabase?.(session); if (!ref) { return undefined; } try { - const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); - if (raw === undefined) { - return undefined; - } - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}`); - return undefined; - } - return parsed - .filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string') - .map(entry => ({ - uri: entry.uri, - ...(typeof entry.providerData === 'string' ? { providerData: entry.providerData } : {}), - ...(entry.origin !== undefined ? { origin: entry.origin } : {}), - ...(typeof entry.inheritedTurnId === 'string' ? { inheritedTurnId: entry.inheritedTurnId } : {}), - })); - } catch (err) { - this._logService.warn(`[AgentService] Failed to read peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); - return undefined; + return await ref.object.getMetadata(DEFAULT_CHAT_PROVIDER_DATA_METADATA_KEY); } finally { ref.dispose(); } @@ -6096,15 +6995,12 @@ export class AgentService extends Disposable implements IAgentService { * Marks a chat's backing SDK session so legacy discovery cannot register * it as a standalone top-level session. Best-effort and never throws: * callers (chat creation / restore) must not fail just because this - * durable write did. The write is retried once; if it still fails, the - * backing session is added to `_unpersistedChatBackings` so - * `_readSessionRegistrationFacts` (external discovery) and `listSessions`'s - * overlay filter keep suppressing it for the rest of this process's lifetime - * even without a persisted marker. A later successful call for the same - * session (e.g. a retried caller) clears any stale suppression entry. + * durable write did. In-process suppression starts before the write and is + * cleared only after the central catalog acknowledges the backing state. */ private async _markChatBacking(backingSession: URI, chat: URI): Promise { const backingSessionStr = backingSession.toString(); + this._unpersistedChatBackings.add(backingSessionStr); const write = async (): Promise => { const ref = this._sessionDataService.openDatabase(backingSession); try { @@ -6115,12 +7011,14 @@ export class AgentService extends Disposable implements IAgentService { }; try { await write(); - this._unpersistedChatBackings.delete(backingSessionStr); + await this._markCatalogPayloadDirty(backingSessionStr); + this._catalogReconciliationService.schedule(); } catch (err) { this._logService.warn(`[AgentService] failed to mark backing session ${backingSessionStr} for chat ${chat.toString()}, retrying`, err); try { await write(); - this._unpersistedChatBackings.delete(backingSessionStr); + await this._markCatalogPayloadDirty(backingSessionStr); + this._catalogReconciliationService.schedule(); } catch (retryErr) { this._logService.warn(`[AgentService] retry failed to mark backing session ${backingSessionStr} for chat ${chat.toString()}; suppressing it in-process instead`, retryErr); this._unpersistedChatBackings.add(backingSessionStr); @@ -6128,89 +7026,49 @@ export class AgentService extends Disposable implements IAgentService { } } - /** - * Inserts or updates a single peer chat in the orchestrator's persisted - * catalog, recording its opaque `providerData` verbatim (or clearing it when - * `undefined`). When `origin` is supplied it is stored as the chat's - * provenance; when omitted (e.g. a provider-driven `providerData` refresh via - * {@link _onChatDataChanged}) any previously persisted origin is preserved so - * a data refresh never drops a side chat's source boundary. Serialized per - * session via {@link _enqueuePeerChatCatalogWrite}. - */ - private _persistPeerChat(session: URI, chat: URI, providerData: string | undefined, origin?: ChatOrigin, inheritedTurnId?: string): Promise { - const chatUri = chat.toString(); - return this._enqueuePeerChatCatalogWrite(session, entries => { - const existing = entries.find(entry => entry.uri === chatUri); - const effectiveOrigin = origin ?? existing?.origin; - const effectiveInheritedTurnId = inheritedTurnId ?? existing?.inheritedTurnId; - const next = entries.filter(entry => entry.uri !== chatUri); - next.push({ - uri: chatUri, - ...(providerData !== undefined ? { providerData } : {}), - ...(effectiveOrigin !== undefined ? { origin: effectiveOrigin } : {}), - ...(effectiveInheritedTurnId !== undefined ? { inheritedTurnId: effectiveInheritedTurnId } : {}), - }); - return next; - }); + private async _isCatalogBackingProjectionPending(session: URI): Promise { + const sessionKey = session.toString(); + if (!this._unpersistedChatBackings.has(sessionKey)) { + return false; + } + try { + if ((await this._orchestratorDatabase.getSessionV2(sessionKey))?.isChatBacking) { + this._unpersistedChatBackings.delete(sessionKey); + return false; + } + } catch (error) { + this._logService.warn(`[AgentService] Failed to verify central backing projection for ${sessionKey}`, error); + } + return true; } - /** - * Removes a peer chat from the orchestrator's persisted catalog. Serialized - * per session via {@link _enqueuePeerChatCatalogWrite}. - */ - private _removePersistedPeerChat(session: URI, chat: URI): Promise { - const chatUri = chat.toString(); - return this._enqueuePeerChatCatalogWrite(session, entries => entries.filter(entry => entry.uri !== chatUri)); + private async _markCatalogPayloadDirty(session: string): Promise { + try { + await this._orchestratorDatabase.markSessionV2PayloadDirty(session); + } catch (error) { + this._logService.warn(`[AgentService] Failed to mark catalog payload dirty for ${session}`, error); + } } - /** - * Chains a read-modify-write of a session's persisted peer-chat catalog - * behind any in-flight write for the same session, so concurrent - * create/dispose/data-change updates can't clobber each other. - */ - private _enqueuePeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { - const key = session.toString(); - const previous = this._peerChatCatalogWrites.get(key) ?? Promise.resolve(); - const next = previous - .catch(() => { /* a failed prior write must not block later ones */ }) - .then(() => this._applyPeerChatCatalogWrite(session, mutate)); - const clear = () => { - if (this._peerChatCatalogWrites.get(key) === tracked) { - this._peerChatCatalogWrites.delete(key); - } - }; - const tracked = next.then(clear, error => { - clear(); - throw error; - }); - this._peerChatCatalogWrites.set(key, tracked); - return tracked; + private async _getChatDraft(session: URI, chatUri: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return undefined; + } + try { + return await ref.object.getChatDraft(chatUri); + } finally { + ref.dispose(); + } } - private async _applyPeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { - const ref = this._sessionDataService.openDatabase(session); + private async _clearChatDraft(session: URI, chatUri: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return; + } try { - let current: IPersistedPeerChat[] = []; - try { - const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); - if (raw !== undefined) { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - current = parsed - .filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string') - .map(entry => ({ - uri: entry.uri, - ...(typeof entry.providerData === 'string' ? { providerData: entry.providerData } : {}), - ...(entry.origin !== undefined ? { origin: entry.origin } : {}), - ...(typeof entry.inheritedTurnId === 'string' ? { inheritedTurnId: entry.inheritedTurnId } : {}), - })); - } - } - } catch (err) { - this._logService.warn(`[AgentService] Replacing malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); - } - const updated = mutate(current); - await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated)); + await ref.object.setChatDraft(chatUri, undefined); } finally { ref.dispose(); } diff --git a/src/vs/platform/agentHost/node/agentSessionRegistry.ts b/src/vs/platform/agentHost/node/agentSessionRegistry.ts index 5b4f3c28516cf2..90d72908cb3fe7 100644 --- a/src/vs/platform/agentHost/node/agentSessionRegistry.ts +++ b/src/vs/platform/agentHost/node/agentSessionRegistry.ts @@ -7,7 +7,7 @@ import { Limiter } from '../../../base/common/async.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { AgentProvider } from '../common/agent.js'; -import { AgentSessionRegistrationSource, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSessionOptions } from './agentHostDatabase.js'; +import { AgentSessionRegistrationSource, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions } from './agentHostDatabase.js'; /** A session recorded in the orchestrator-owned {@link AgentSessionRegistry}. */ export interface IRegisteredSession { @@ -68,12 +68,12 @@ export class AgentSessionRegistry extends Disposable { /** Records a session using source-aware provenance and tombstone behavior. */ register(session: URI, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { - return this._database.registerSession(session.toString(), sessionOptions, registerOptions); + return this._database.registerRuntimeSession(session.toString(), sessionOptions, registerOptions); } /** Removes any registry entry for `session` without writing a tombstone. */ async unregister(session: URI): Promise { - await this._database.unregisterSession(session.toString()); + await this._database.unregisterRuntimeSession(session.toString()); } /** @@ -99,25 +99,28 @@ export class AgentSessionRegistry extends Disposable { /** Every registered session URI key without running legacy metadata migration. */ async listSessionKeys(): Promise> { - return new Set((await this._database.listSessions()).map(entry => entry.session)); + return new Set((await this._database.listSessionV2Registrations()).map(entry => entry.session)); + } + + /** Current and legacy identity keys used only to deduplicate cooling-period discovery. */ + async listRuntimeCompatibleSessionKeys(): Promise> { + return new Set(await this._database.listRuntimeCompatibleSessionKeys()); } /** - * Every registered session URI mapped to its durable last-observed - * modification time, without running legacy metadata migration. Lets a - * caller skip no-op recency writes for sessions the provider re-reports - * unchanged. + * Every current session URI mapped to its durable last-observed modification + * time, without running legacy metadata migration. */ async listSessionModifiedTimes(): Promise> { - return new Map((await this._database.listSessions()).map(entry => [entry.session, entry.modifiedTime])); + return new Map((await this._database.listSessionV2Registrations()).map(entry => [entry.session, entry.modifiedTime])); } /** - * Every session currently recorded, in no particular order. Legacy entries - * are passed through `migrate`, when provided, before the resolved list is returned. + * Every current registry identity, in no particular order. Entries with + * unresolved provenance are passed through `migrate`, when provided. */ async list(migrate?: RegisteredSessionMigration): Promise { - const entries: IStoredRegisteredSession[] = (await this._database.listSessions()).map(entry => ({ + const entries: IStoredRegisteredSession[] = (await this._database.listSessionV2Registrations()).map(entry => ({ session: URI.parse(entry.session), provider: entry.provider, startTime: entry.startTime, @@ -148,14 +151,14 @@ export class AgentSessionRegistry extends Disposable { }; }); if (updates.length > 0) { - await this._database.updateSessionExternal(updates); + await this._database.updateRuntimeSessionExternal(updates); } return result; } /** Returns the session registered under `session`, or `undefined` when it is unknown. */ async get(session: URI, migrate?: RegisteredSessionMigration): Promise { - const stored = await this._database.getSession(session.toString()); + const stored = await this._database.getSessionV2Registration(session.toString()); if (!stored) { return undefined; } @@ -169,7 +172,7 @@ export class AgentSessionRegistry extends Disposable { }; const migrated = await migrate?.(entry); if (migrated) { - await this._database.updateSessionExternal([{ session: migrated.session.toString(), external: migrated.external }]); + await this._database.updateRuntimeSessionExternal([{ session: migrated.session.toString(), external: migrated.external }]); return migrated; } if (entry.external === undefined) { @@ -183,7 +186,7 @@ export class AgentSessionRegistry extends Disposable { /** Whether the registry has ever been populated. Retained for compatibility. */ async isEmpty(): Promise { - return this._database.isSessionRegistryEmpty(); + return this._database.isSessionV2RegistryEmpty(); } /** @@ -213,6 +216,44 @@ export class AgentSessionRegistry extends Disposable { await this._database.markProviderBackfilled(provider); } + /** Whether a provider completed the current registry projection backfill. */ + async isSessionsV2Backfilled(provider: AgentProvider, projectionVersion: number): Promise { + return this._database.isSessionsV2Backfilled(provider, projectionVersion); + } + + /** Records completion of a provider's current registry projection backfill. */ + async markSessionsV2Backfilled(provider: AgentProvider, projectionVersion: number): Promise { + await this._database.markSessionsV2Backfilled(provider, projectionVersion); + } + + /** Durably excludes a non-deleted session from the current v2 catalog. */ + async markSessionsV2Excluded(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise { + await this._database.markSessionsV2Excluded(exclusion); + } + + async markSessionsV2ExcludedBatch(exclusions: readonly IAgentHostDatabaseSessionsV2Exclusion[]): Promise { + if (this._database.markSessionsV2ExcludedBatch) { + await this._database.markSessionsV2ExcludedBatch(exclusions); + } else { + await Promise.all(exclusions.map(exclusion => this._database.markSessionsV2Excluded(exclusion))); + } + } + + /** Reads a durable current-v2 exclusion for one session. */ + getSessionsV2Exclusion(provider: AgentProvider, session: URI): Promise { + return this._database.getSessionsV2Exclusion(provider, session.toString()); + } + + /** Lists durable current-v2 exclusions for one provider. */ + listSessionsV2Exclusions(provider: AgentProvider): Promise { + return this._database.listSessionsV2Exclusions(provider); + } + + /** Clears a durable current-v2 exclusion when the session becomes eligible. */ + async clearSessionsV2Exclusion(provider: AgentProvider, session: URI): Promise { + await this._database.clearSessionsV2Exclusion(provider, session.toString()); + } + /** Whether `session` was explicitly deleted and must not be resurrected by backfill. */ async isTombstoned(session: URI): Promise { return this._database.isSessionTombstoned(session.toString()); diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts index 35e2f997ba097b..9f71bf40929992 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts @@ -50,9 +50,11 @@ export class SessionTitleContribution extends Disposable implements IAgentHostCh if (isAhpChatChannel(observed.channel)) { this._stateManager.updateChatTitle(observed.session, observed.channel, observed.action.title); + this._persistSessionMetadata(observed.channel, SESSION_CUSTOM_TITLE_KEY, observed.action.title); + this._persistSessionMetadata(observed.channel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_USER); this._persistSessionMetadata(observed.session, customChatTitleMetadataKey(observed.channel), observed.action.title); this._persistSessionMetadata(observed.session, customChatTitleSourceMetadataKey(observed.channel), AGENT_HOST_TITLE_SOURCE_USER); - this._titleController.markTitleRenamed(observed.session, observed.channel); + this._titleController.markTitleRenamed(observed.session, observed.channel, observed.action.title); if (isDefaultChatUri(observed.channel)) { this._stateManager.dispatchServerAction(observed.session, observed.action); this._persistSessionMetadata(observed.session, SESSION_CUSTOM_TITLE_KEY, observed.action.title); @@ -72,6 +74,19 @@ export class SessionTitleContribution extends Disposable implements IAgentHostCh * catalog-registration time so a restored peer chat shows its title before its turns load. */ async onHydrateChat(context: IHydrationContext, restored: IRestoredChat): Promise { + const chatRef = await this._sessionDataService.tryOpenDatabase(URI.parse(context.chat)); + if (chatRef) { + try { + const title = await chatRef.object.getMetadata(SESSION_CUSTOM_TITLE_KEY); + if (title !== undefined) { + return { ...restored, title }; + } + } catch (err) { + this._logService.warn(`[SessionTitleContribution] Failed to restore chat-local title for ${context.chat}`, err); + } finally { + chatRef.dispose(); + } + } if (restored.title !== undefined) { return restored; } @@ -80,7 +95,6 @@ export class SessionTitleContribution extends Disposable implements IAgentHostCh if (!ref) { return restored; } - try { const title = (await ref.object.getMetadata(customChatTitleMetadataKey(context.chat))) ?? undefined; return title !== undefined ? { ...restored, title } : restored; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index bc81eb16dabb49..15369caed845ea 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -67,6 +67,7 @@ import { ActiveClientToolSet, structuralToolsEqual } from '../activeClientState. import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostManagedSettingsService } from '../agentHostManagedSettingsService.js'; import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; +import { AGENT_HOST_TITLE_SOURCE_AUTO, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../shared/persistSessionMetadata.js'; import { IAgentHostCompletions } from '../agentHostCompletions.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { applyMcpServerEnablement, buildMcpTopLevelCustomizationId, type IMcpServerRuntimeState } from '../shared/mcpCustomizationController.js'; @@ -3547,7 +3548,7 @@ export class CopilotAgent extends Disposable implements IAgent { async ensureChatAdopted(chat: URI, context: URI | IAgentChatContext): Promise { const session = resolveAgentChatContext(context, chat).configurationResource; const sessionId = AgentSession.id(session); - return this._queueSession(sessionId, async () => { + return this._queueSession(sessionId, async (): Promise => { // A genuine native / already-adopted session always has a persisted // working directory. The session DB FILE can also exist without any // real metadata (checkpoint / changeset / git services create it via @@ -3556,6 +3557,7 @@ export class CopilotAgent extends Disposable implements IAgent { const existing = await this._readStoredSessionMetadata(session); if (existing?.workingDirectory) { await this._backfillAdoptedLegacyMarker(session, sessionId); + await this._backfillAdoptedLegacyListVisibleMetadata(session, sessionId); this._logService.trace(`[Copilot] Adoption skipped for ${sessionId}: already has Agent Host metadata (cwd=${existing.workingDirectory.fsPath})`); return { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }; } @@ -3621,13 +3623,65 @@ export class CopilotAgent extends Disposable implements IAgent { // chat editor can substitute the session-wide changeset for that migrated // (checkpoint-less) turn without misattributing it to a later, post-adoption turn. const lastMigratedTurnId = await this._readExtensionHostCliLastTurnId(sessionId); - await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, adoptedTitle, /* markRead */ true, archived, /* ehcliAdopted */ true, lastMigratedTurnId); + await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, archived, /* ehcliAdopted */ true, lastMigratedTurnId); + const titleSource: 'user' | 'auto' = adoptedTitle === customTitle ? 'user' : AGENT_HOST_TITLE_SOURCE_AUTO; + const listVisible: IAgentChatAdoptionResult['listVisible'] = adoptedTitle !== undefined + ? { title: adoptedTitle, titleSource, isRead: true } + : { isRead: true }; + const metadataRef = this._sessionDataService.openDatabase(session); + try { + await metadataRef.object.setMetadataValues({ + [AH_META_IS_READ_DB_KEY]: 'true', + ...(adoptedTitle !== undefined ? { + [SESSION_CUSTOM_TITLE_KEY]: adoptedTitle, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: titleSource, + } : {}), + }); + } finally { + metadataRef.dispose(); + } await this._adoptLegacyTurnUsage(session, sessionId); this._logService.info(`[Copilot] Adopted legacy session ${sessionId}: project=${project ? project.uri.fsPath : '(unresolved)'} archived=${archived} title=${adoptedTitle !== undefined ? (cliName ? 'name' : customTitle ? 'custom' : 'summary') : 'none'} worktreeBridged=${!!adoptedWorktree}`); - return { adopted: true, eligible: true, reason: 'adopted', ...(adoptedWorktree ? { worktree: adoptedWorktree } : {}) }; + return { adopted: true, eligible: true, reason: 'adopted', listVisible, ...(adoptedWorktree ? { worktree: adoptedWorktree } : {}) }; }); } + private async _backfillAdoptedLegacyListVisibleMetadata(session: URI, sessionId: string): Promise { + try { + if (!await this._isExtensionHostCliSession(sessionId)) { + return; + } + const customTitle = await this._readExtensionHostCliCustomTitle(sessionId); + const ref = this._sessionDataService.openDatabase(session); + try { + const existing = await ref.object.getMetadataObject({ + [AH_META_IS_READ_DB_KEY]: true, + [SESSION_CUSTOM_TITLE_KEY]: true, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: true, + }); + const missing: Record = {}; + if (existing[AH_META_IS_READ_DB_KEY] === undefined) { + missing[AH_META_IS_READ_DB_KEY] = 'true'; + } + if (customTitle !== undefined && existing[SESSION_CUSTOM_TITLE_KEY] === undefined) { + missing[SESSION_CUSTOM_TITLE_KEY] = customTitle; + } + if (customTitle !== undefined + && (existing[SESSION_CUSTOM_TITLE_KEY] === undefined || existing[SESSION_CUSTOM_TITLE_KEY] === customTitle) + && existing[SESSION_CUSTOM_TITLE_SOURCE_KEY] === undefined) { + missing[SESSION_CUSTOM_TITLE_SOURCE_KEY] = 'user'; + } + if (Object.keys(missing).length > 0) { + await ref.object.setMetadataValues(missing); + } + } finally { + ref.dispose(); + } + } catch (error) { + this._logService.warn(`[Copilot] Failed to backfill adopted legacy list metadata for ${sessionId}`, error); + } + } + /** * Carries the per-request credit totals the extension host persisted in * `vscode.requests.metadata.json` into the adopted session's `turn_usage` @@ -5325,7 +5379,7 @@ export class CopilotAgent extends Disposable implements IAgent { } - private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, workingDirectories: readonly URI[] | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined, configValues?: Record, customTitle?: string, markRead?: boolean, archived?: boolean, ehcliAdopted?: boolean, lastMigratedTurnId?: string): Promise { + private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, workingDirectories: readonly URI[] | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined, configValues?: Record, archived?: boolean, ehcliAdopted?: boolean, lastMigratedTurnId?: string): Promise { const dbRef = this._sessionDataService.openDatabase(session); const db = dbRef.object; try { @@ -5333,10 +5387,6 @@ export class CopilotAgent extends Disposable implements IAgent { if (model) { work.push(db.setMetadata(CopilotAgent._META_MODEL, this._serializeModelSelection(model))); } - // Persist read ownership so the adopted session isn't reported unread on open. - if (markRead) { - work.push(db.setMetadata(AH_META_IS_READ_DB_KEY, 'true')); - } // Archiving is user-curated state; losing it on adoption would resurface // everything the user filed away in the extension host list. if (archived) { @@ -5380,12 +5430,6 @@ export class CopilotAgent extends Disposable implements IAgent { if (configValues) { work.push(db.setMetadata('configValues', JSON.stringify(configValues))); } - // Overlaid as the session's display title on restore (see the - // `customTitle` overlay in `AgentService`); used by adopt to carry - // over the legacy extension-host session name. - if (customTitle) { - work.push(db.setMetadata('customTitle', customTitle)); - } await Promise.all(work); } finally { dbRef.dispose(); diff --git a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts index 02bb256d26cbe4..2d944649753fd3 100644 --- a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts @@ -49,7 +49,7 @@ export interface ILocalChatCommandContext { /** Persist a session-metadata key/value pair (e.g. a custom title). */ persistSessionFlag(session: ProtocolURI, key: string, value: string): void; /** Suppress automatic naming after a local user rename. */ - markTitleRenamed(session: ProtocolURI, chat?: ProtocolURI): void; + markTitleRenamed(session: ProtocolURI, chat?: ProtocolURI, title?: string): void; } /** @@ -155,7 +155,7 @@ export class AgentHostLocalCommands extends Disposable { getState: channel => this._stateManager.getSessionState(channel), updateChatTitle: (session, chat, title) => this._stateManager.updateChatTitle(session, chat, title), persistSessionFlag: (session, key, value) => persistSessionMetadata(this._sessionDataService, this._logService, session, key, value), - markTitleRenamed: (session, chat) => this._titleController.markTitleRenamed(session, chat), + markTitleRenamed: (session, chat, title) => this._titleController.markTitleRenamed(session, chat, title), }; this._commands = LocalChatCommandRegistry.createAll(context).map(command => this._register(command)); } diff --git a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts index f3d222b79f4ba9..ebcde2f692c180 100644 --- a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts @@ -44,7 +44,7 @@ export class RenameLocalCommand extends Disposable implements ILocalChatCommand const sessionChannel = isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel; if (chatTarget) { this._context.updateChatTitle(sessionChannel, chatTarget, title); - this._context.markTitleRenamed(sessionChannel, chatTarget); + this._context.markTitleRenamed(sessionChannel, chatTarget, title); this._context.persistSessionFlag(sessionChannel, customChatTitleMetadataKey(chatTarget), title); this._context.persistSessionFlag(sessionChannel, customChatTitleSourceMetadataKey(chatTarget), AGENT_HOST_TITLE_SOURCE_USER); if (isDefaultChatUri(chatTarget)) { diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 9d038c7aad43ce..200bd48707f6e9 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import { Sequencer, SequencerByKey } from '../../../base/common/async.js'; import type { Database, RunResult } from '@vscode/sqlite3'; -import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase } from '../common/sessionDataService.js'; +import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionCatalogSyncSnapshot, ISessionDatabase, SessionCatalogSyncWriteResult } from '../common/sessionDataService.js'; import { dirname } from '../../../base/common/path.js'; import { URI } from '../../../base/common/uri.js'; import type { Message } from '../common/state/sessionState.js'; @@ -142,6 +142,30 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ delegation TEXT NOT NULL )`, }, + { + version: 11, + // Both tables are repeated with IF NOT EXISTS so databases created by + // either pre-merge v10 migration converge on the combined schema. + sql: [`CREATE TABLE IF NOT EXISTS turn_delegation ( + turn_id TEXT PRIMARY KEY NOT NULL REFERENCES turns(id) ON DELETE CASCADE, + delegation TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS catalog_sync_snapshot ( + singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), + session_generation TEXT NOT NULL CHECK (length(session_generation) > 0), + source_revision INTEGER NOT NULL CHECK (source_revision >= 0), + projection_version INTEGER NOT NULL CHECK (projection_version >= 0), + acknowledged_hash TEXT, + pending_hash TEXT, + pending_payload TEXT, + CHECK (acknowledged_hash IS NULL OR length(acknowledged_hash) > 0), + CHECK ( + (pending_hash IS NULL AND pending_payload IS NULL) + OR (length(pending_hash) > 0 AND pending_payload IS NOT NULL) + ), + CHECK (acknowledged_hash IS NOT NULL OR pending_hash IS NOT NULL) + )`].join(';\n'), + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -204,6 +228,70 @@ function dbOpen(path: string): Promise { }); } +function validateCatalogSyncInteger(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Catalog sync ${name} must be a non-negative safe integer`); + } +} + +function validateCatalogSyncIdentity(name: string, value: unknown): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Catalog sync ${name} must be nonempty`); + } +} + +function validateCatalogSyncSnapshot(snapshot: ISessionCatalogSyncPendingSnapshot): void { + validateCatalogSyncIdentity('sessionGeneration', snapshot.sessionGeneration); + validateCatalogSyncInteger('sourceRevision', snapshot.sourceRevision); + validateCatalogSyncInteger('projectionVersion', snapshot.projectionVersion); + validateCatalogSyncIdentity('payload', snapshot.payload); + validateCatalogSyncIdentity('payloadHash', snapshot.payloadHash); +} + +function validateCatalogSyncAcknowledgement(acknowledgement: ISessionCatalogSyncAcknowledgement): void { + validateCatalogSyncIdentity('sessionGeneration', acknowledgement.sessionGeneration); + validateCatalogSyncInteger('sourceRevision', acknowledgement.sourceRevision); + validateCatalogSyncInteger('projectionVersion', acknowledgement.projectionVersion); + validateCatalogSyncIdentity('payloadHash', acknowledgement.payloadHash); +} + +function toCatalogSyncSnapshot(row: Record): ISessionCatalogSyncSnapshot { + validateCatalogSyncIdentity('sessionGeneration', row.session_generation); + validateCatalogSyncInteger('sourceRevision', row.source_revision as number); + validateCatalogSyncInteger('projectionVersion', row.projection_version as number); + const acknowledgedHash = row.acknowledged_hash; + let validatedAcknowledgedHash: string | undefined; + if (acknowledgedHash !== null) { + validateCatalogSyncIdentity('acknowledgedHash', acknowledgedHash); + validatedAcknowledgedHash = acknowledgedHash; + } + if (row.pending_hash !== null) { + validateCatalogSyncIdentity('pendingHash', row.pending_hash); + if (typeof row.pending_payload !== 'string') { + throw new Error('Catalog sync pending payload must be a string'); + } + return { + sessionGeneration: row.session_generation, + sourceRevision: row.source_revision as number, + projectionVersion: row.projection_version as number, + payload: row.pending_payload, + payloadHash: row.pending_hash, + acknowledgedHash: validatedAcknowledgedHash, + state: 'pending', + }; + } + validateCatalogSyncIdentity('acknowledgedHash', acknowledgedHash); + return { + sessionGeneration: row.session_generation, + sourceRevision: row.source_revision as number, + projectionVersion: row.projection_version as number, + payload: undefined, + payloadHash: acknowledgedHash, + acknowledgedHash, + state: 'acknowledged', + }; +} + /** * Applies any pending {@link ISessionDatabaseMigration migrations} to a * database. Migrations whose version is greater than the current @@ -736,6 +824,81 @@ export class SessionDatabase implements ISessionDatabase { })); } + async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + validateCatalogSyncSnapshot(snapshot); + return this._track(() => this._metadataSequencer.queue(async () => { + const db = await this._ensureDb(); + return this._transactionSequencer.queue(async () => { + await dbExec(db, 'BEGIN TRANSACTION'); + try { + const existingRow = await dbGet(db, 'SELECT session_generation, source_revision, projection_version, acknowledged_hash, pending_hash, pending_payload FROM catalog_sync_snapshot WHERE singleton_id = 1', []); + const existing = existingRow ? toCatalogSyncSnapshot(existingRow) : undefined; + if (existing && snapshot.sessionGeneration !== existing.sessionGeneration) { + throw new Error(`Catalog sync snapshot generation ${snapshot.sessionGeneration} does not match stored generation ${existing.sessionGeneration}`); + } + if (existing && snapshot.sourceRevision < existing.sourceRevision) { + throw new Error(`Catalog sync snapshot revision ${snapshot.sourceRevision} is stale; current revision is ${existing.sourceRevision}`); + } + if (existing && snapshot.sourceRevision === existing.sourceRevision) { + const isExactReplay = snapshot.sessionGeneration === existing.sessionGeneration + && snapshot.projectionVersion === existing.projectionVersion + && snapshot.payloadHash === existing.payloadHash + && (existing.state === 'acknowledged' || snapshot.payload === existing.payload); + if (!isExactReplay) { + throw new Error(`Catalog sync snapshot revision ${snapshot.sourceRevision} conflicts with the stored snapshot`); + } + } + + const result: SessionCatalogSyncWriteResult = existing?.sourceRevision === snapshot.sourceRevision ? 'replayed' : 'applied'; + if (result === 'replayed') { + await dbExec(db, 'COMMIT'); + return result; + } + for (const [key, value] of Object.entries(values)) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); + } + await this._writeCatalogSyncSnapshot(db, snapshot, existing?.acknowledgedHash); + await dbExec(db, 'COMMIT'); + return result; + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; + } + }); + })); + } + + async transitionMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, expectedSessionGeneration: string, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + validateCatalogSyncIdentity('expectedSessionGeneration', expectedSessionGeneration); + validateCatalogSyncSnapshot(snapshot); + if (snapshot.sessionGeneration === expectedSessionGeneration) { + throw new Error(`Catalog sync generation transition must change the session generation`); + } + return this._track(() => this._metadataSequencer.queue(async () => { + const db = await this._ensureDb(); + return this._transactionSequencer.queue(async () => { + await dbExec(db, 'BEGIN TRANSACTION'); + try { + const existingRow = await dbGet(db, 'SELECT session_generation, source_revision, projection_version, acknowledged_hash, pending_hash, pending_payload FROM catalog_sync_snapshot WHERE singleton_id = 1', []); + const existing = existingRow ? toCatalogSyncSnapshot(existingRow) : undefined; + if (!existing || existing.sessionGeneration !== expectedSessionGeneration) { + await dbExec(db, 'COMMIT'); + return false; + } + for (const [key, value] of Object.entries(values)) { + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); + } + await this._writeCatalogSyncSnapshot(db, snapshot, undefined); + await dbExec(db, 'COMMIT'); + return true; + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; + } + }); + })); + } + setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { return this._track(() => this._metadataSequencer.queue(async () => { const db = await this._ensureDb(); @@ -763,6 +926,58 @@ export class SessionDatabase implements ISessionDatabase { })); } + getCatalogSyncSnapshot(): Promise { + return this._metadataSequencer.queue(async () => { + const db = await this._ensureDb(); + const row = await dbGet(db, 'SELECT session_generation, source_revision, projection_version, acknowledged_hash, pending_hash, pending_payload FROM catalog_sync_snapshot WHERE singleton_id = 1', []); + return row ? toCatalogSyncSnapshot(row) : undefined; + }); + } + + async acknowledgeCatalogSyncSnapshot(acknowledgement: ISessionCatalogSyncAcknowledgement): Promise { + validateCatalogSyncAcknowledgement(acknowledgement); + return this._track(() => this._metadataSequencer.queue(async () => { + const db = await this._ensureDb(); + return this._transactionSequencer.queue(async () => { + const result = await dbRun(db, `UPDATE catalog_sync_snapshot + SET acknowledged_hash = pending_hash, + pending_hash = NULL, + pending_payload = NULL + WHERE singleton_id = 1 + AND session_generation = ? + AND source_revision = ? + AND projection_version = ? + AND pending_hash = ?`, [ + acknowledgement.sessionGeneration, + acknowledgement.sourceRevision, + acknowledgement.projectionVersion, + acknowledgement.payloadHash, + ]); + return result.changes === 1; + }); + })); + } + + private async _writeCatalogSyncSnapshot(db: Database, snapshot: ISessionCatalogSyncPendingSnapshot, acknowledgedHash: string | undefined): Promise { + await dbRun(db, `INSERT INTO catalog_sync_snapshot ( + singleton_id, session_generation, source_revision, projection_version, acknowledged_hash, pending_hash, pending_payload + ) VALUES (1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(singleton_id) DO UPDATE SET + session_generation = excluded.session_generation, + source_revision = excluded.source_revision, + projection_version = excluded.projection_version, + acknowledged_hash = excluded.acknowledged_hash, + pending_hash = excluded.pending_hash, + pending_payload = excluded.pending_payload`, [ + snapshot.sessionGeneration, + snapshot.sourceRevision, + snapshot.projectionVersion, + acknowledgedHash, + snapshot.payloadHash, + snapshot.payload, + ]); + } + setChatDraft(chat: URI, draft: Message | undefined): Promise { const chatUri = chat.toString(); return this._track(async () => { diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 5e043dc1af549a..c76be90d757b9b 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -8,7 +8,7 @@ import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { Event } from '../../../../base/common/event.js'; import type { IDetailedDiffResult, IDiffComputeService, IDiffCountResult } from '../../common/diffComputeService.js'; -import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionCatalogSyncSnapshot, ISessionDatabase, ISessionDataService, SessionCatalogSyncWriteResult } from '../../common/sessionDataService.js'; import type { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import type { ISessionGitHubState, Message } from '../../common/state/sessionState.js'; @@ -16,6 +16,7 @@ import type { ISessionGitHubState, Message } from '../../common/state/sessionSta export class TestSessionDatabase implements ISessionDatabase { private readonly _edits: (IFileEditRecord & IFileEditContent)[] = []; private readonly _metadata = new Map(); + private _catalogSyncSnapshot: ISessionCatalogSyncSnapshot | undefined; private readonly _drafts = new Map(); private readonly _reviewedFiles: IReviewedFileRecord[] = []; private readonly _localTurns = new Map(); @@ -94,6 +95,81 @@ export class TestSessionDatabase implements ISessionDatabase { } } + async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + this._validateCatalogSyncSnapshot(snapshot); + const existing = this._catalogSyncSnapshot; + if (existing && snapshot.sessionGeneration !== existing.sessionGeneration) { + throw new Error(`Catalog sync snapshot generation ${snapshot.sessionGeneration} does not match stored generation ${existing.sessionGeneration}`); + } + if (existing && snapshot.sourceRevision < existing.sourceRevision) { + throw new Error(`Catalog sync snapshot revision ${snapshot.sourceRevision} is stale; current revision is ${existing.sourceRevision}`); + } + if (existing && snapshot.sourceRevision === existing.sourceRevision) { + const isExactReplay = snapshot.sessionGeneration === existing.sessionGeneration + && snapshot.projectionVersion === existing.projectionVersion + && snapshot.payloadHash === existing.payloadHash + && (existing.state === 'acknowledged' || snapshot.payload === existing.payload); + if (!isExactReplay) { + throw new Error(`Catalog sync snapshot revision ${snapshot.sourceRevision} conflicts with the stored snapshot`); + } + } + + if (existing?.sourceRevision === snapshot.sourceRevision) { + return 'replayed'; + } + for (const [key, value] of Object.entries(values)) { + this.setMetadataCalls.push({ key, value }); + this._metadata.set(key, value); + } + this._catalogSyncSnapshot = { ...snapshot, acknowledgedHash: existing?.acknowledgedHash }; + return 'applied'; + } + + async transitionMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, expectedSessionGeneration: string, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + this._validateCatalogSyncIdentity('expectedSessionGeneration', expectedSessionGeneration); + this._validateCatalogSyncSnapshot(snapshot); + if (snapshot.sessionGeneration === expectedSessionGeneration) { + throw new Error(`Catalog sync generation transition must change the session generation`); + } + if (this._catalogSyncSnapshot?.sessionGeneration !== expectedSessionGeneration) { + return false; + } + for (const [key, value] of Object.entries(values)) { + this.setMetadataCalls.push({ key, value }); + this._metadata.set(key, value); + } + this._catalogSyncSnapshot = { ...snapshot, acknowledgedHash: undefined }; + return true; + } + + async getCatalogSyncSnapshot(): Promise { + return this._catalogSyncSnapshot ? { ...this._catalogSyncSnapshot } : undefined; + } + + async acknowledgeCatalogSyncSnapshot(acknowledgement: ISessionCatalogSyncAcknowledgement): Promise { + this._validateCatalogSyncAcknowledgement(acknowledgement); + const snapshot = this._catalogSyncSnapshot; + if (!snapshot + || snapshot.state !== 'pending' + || acknowledgement.sessionGeneration !== snapshot.sessionGeneration + || acknowledgement.sourceRevision !== snapshot.sourceRevision + || acknowledgement.projectionVersion !== snapshot.projectionVersion + || acknowledgement.payloadHash !== snapshot.payloadHash + ) { + return false; + } + this._catalogSyncSnapshot = { + sessionGeneration: snapshot.sessionGeneration, + sourceRevision: snapshot.sourceRevision, + projectionVersion: snapshot.projectionVersion, + payload: undefined, + payloadHash: snapshot.payloadHash, + acknowledgedHash: snapshot.payloadHash, + state: 'acknowledged', + }; + return true; + } + async setMetadataValuesIfAbsent(key: string, values: Readonly>, copies: Readonly> = {}): Promise { if (this._metadata.has(key)) { return false; @@ -246,6 +322,33 @@ export class TestSessionDatabase implements ISessionDatabase { async whenIdle(): Promise { } + private _validateCatalogSyncSnapshot(snapshot: ISessionCatalogSyncPendingSnapshot): void { + this._validateCatalogSyncIdentity('sessionGeneration', snapshot.sessionGeneration); + this._validateCatalogSyncInteger('sourceRevision', snapshot.sourceRevision); + this._validateCatalogSyncInteger('projectionVersion', snapshot.projectionVersion); + this._validateCatalogSyncIdentity('payload', snapshot.payload); + this._validateCatalogSyncIdentity('payloadHash', snapshot.payloadHash); + } + + private _validateCatalogSyncAcknowledgement(acknowledgement: ISessionCatalogSyncAcknowledgement): void { + this._validateCatalogSyncIdentity('sessionGeneration', acknowledgement.sessionGeneration); + this._validateCatalogSyncInteger('sourceRevision', acknowledgement.sourceRevision); + this._validateCatalogSyncInteger('projectionVersion', acknowledgement.projectionVersion); + this._validateCatalogSyncIdentity('payloadHash', acknowledgement.payloadHash); + } + + private _validateCatalogSyncInteger(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Catalog sync ${name} must be a non-negative safe integer`); + } + } + + private _validateCatalogSyncIdentity(name: string, value: string): void { + if (value.length === 0) { + throw new Error(`Catalog sync ${name} must be nonempty`); + } + } + private _toEditRecords(edits: (IFileEditRecord & IFileEditContent)[]): IFileEditRecord[] { return edits.map(({ beforeContent: _, afterContent: _2, ...metadata }) => metadata); } diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts new file mode 100644 index 00000000000000..03277fe80bf9c9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts @@ -0,0 +1,212 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentSession } from '../../common/agent.js'; +import { readSessionArtifacts, SESSION_META_ARTIFACTS_KEY } from '../../common/sessionArtifacts.js'; +import { isSessionStatusArchived, isSessionStatusRead, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY } from '../../common/state/sessionState.js'; +import { AgentHostCatalogListReader } from '../../node/agentHostCatalogListReader.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, encodeAgentHostCatalogPayload, type AgentHostCatalogData } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostDatabase, type IAgentHostDatabaseSessionV2 } from '../../node/agentHostDatabase.js'; +import type { IRegisteredSession } from '../../node/agentSessionRegistry.js'; + +class TestCatalogDatabase extends AgentHostDatabase { + catalog: IAgentHostDatabaseSessionV2 | undefined; + readError: Error | undefined; + + constructor() { + super(':memory:'); + } + + override async getSessionV2(): Promise { + if (this.readError) { + throw this.readError; + } + return this.catalog; + } +} + +suite('AgentHostCatalogListReader', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const session = AgentSession.uri('copilot', 'central-list'); + const registered: IRegisteredSession = { + session, + provider: 'copilot', + startTime: 100, + modifiedTime: 100, + external: true, + source: 'discovery', + }; + const data: AgentHostCatalogData = { + modifiedTime: 200, + summary: 'Catalog title', + titleSource: 'user', + isRead: true, + isArchived: true, + project: { uri: 'file:///workspace', displayName: 'Workspace' }, + workingDirectories: ['file:///workspace', 'file:///other'], + changes: { additions: 4, deletions: 2, files: 3 }, + _meta: { + [SESSION_META_MULTI_ROOT_KEY]: { workspaceFile: 'file:///workspace/project.code-workspace' }, + [SESSION_META_FOLDER_PICKER_KEY]: { hidden: true, primary: 'file:///workspace' }, + [SESSION_META_GITHUB_KEY]: { owner: 'microsoft', repo: 'vscode', pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'] }, + [SESSION_META_GIT_KEY]: { + hasGitHubRemote: true, + branchName: 'feature', + baseBranchName: 'main', + upstreamBranchName: 'origin/feature', + incomingChanges: 1, + outgoingChanges: 2, + uncommittedChanges: 3, + hasBaseBranchChanges: true, + githubOwner: 'microsoft', + githubHeadOwner: 'contributor', + githubRepo: 'vscode', + }, + [SESSION_META_SOURCE_CONTROL_KEY]: { merge: { commit: 'abc123' }, latestOutcome: 'pullRequest' }, + [SESSION_META_ARTIFACTS_KEY]: [{ id: 'artifact', type: 'pullRequest', label: 'PR', isArtifact: true, link: 'https://github.com/microsoft/vscode/pull/1', isGitHub: true }], + [SESSION_META_CREATED_BY_SESSION_KEY]: { + session: 'agent-session://copilot/creator', + chat: 'agent-chat://copilot/creator/default', + turnId: 'turn-1', + }, + [SESSION_META_WORKSPACELESS_KEY]: true, + [SESSION_META_EHCLI_ADOPTABLE_KEY]: true, + }, + chats: [ + { uri: `${session.toString()}/chat/default`, order: 0, kind: 'default', summary: 'Catalog title', titleSource: 'user' }, + { uri: `${session.toString()}/chat/peer`, order: 1, kind: 'peer', summary: 'Peer title', titleSource: 'agent', origin: { kind: 'fork', chat: `${session.toString()}/chat/default`, turnId: 'turn-1' } }, + ], + }; + + function encode(catalogData: AgentHostCatalogData): { readonly payload: string; readonly payloadHash: string } { + const encoded = encodeAgentHostCatalogPayload(catalogData); + if (!encoded.ok) { + throw new Error(encoded.error); + } + return encoded.value; + } + + function createDatabase(catalogData: AgentHostCatalogData = data): TestCatalogDatabase { + const database = disposables.add(new TestCatalogDatabase()); + const encoded = encode(catalogData); + database.catalog = { + session: session.toString(), + modifiedTime: 100, + sessionGeneration: 'incarnation', + sourceRevision: 2, + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + payloadHash: encoded.payloadHash, + verified: true, + payload: encoded.payload, + isChatBacking: catalogData.isChatBacking === true, + payloadDirty: 0, + provider: registered.provider, + startTime: registered.startTime, + external: registered.external, + source: registered.source, + }; + return database; + } + + test('converts a verified catalog payload into complete list metadata and chats', async () => { + const result = await new AgentHostCatalogListReader(createDatabase()).read(registered); + assert.strictEqual(result.eligible, true); + if (!result.eligible) { + return; + } + + assert.deepStrictEqual({ + session: result.metadata.session.toString(), + startTime: result.metadata.startTime, + modifiedTime: result.metadata.modifiedTime, + summary: result.metadata.summary, + isRead: isSessionStatusRead(result.metadata.status), + isArchived: isSessionStatusArchived(result.metadata.status), + project: result.metadata.project && { uri: result.metadata.project.uri.toString(), displayName: result.metadata.project.displayName }, + workingDirectories: result.metadata.workingDirectories?.map(directory => directory.toString()), + changes: result.metadata.changes, + external: readSessionExternal(result.metadata._meta), + workspaceless: readSessionWorkspaceless(result.metadata._meta), + ehcliAdoptable: readSessionEhcliAdoptable(result.metadata._meta), + multiRoot: readSessionMultiRootMetadata(result.metadata._meta), + folderPicker: readSessionFolderPickerDecision(result.metadata._meta), + github: readSessionGitHubState(result.metadata._meta), + git: readSessionGitState(result.metadata._meta), + sourceControl: readSessionSourceControlState(result.metadata._meta), + artifacts: readSessionArtifacts(result.metadata._meta), + creationReference: readSessionCreationReference(result.metadata._meta), + chats: result.data.chats.map(chat => ({ ...chat, uri: chat.uri.toString() })), + }, { + session: session.toString(), + startTime: 100, + modifiedTime: 200, + summary: 'Catalog title', + isRead: true, + isArchived: true, + project: { uri: 'file:///workspace', displayName: 'Workspace' }, + workingDirectories: ['file:///workspace', 'file:///other'], + changes: data.changes, + external: true, + workspaceless: true, + ehcliAdoptable: true, + multiRoot: data._meta?.[SESSION_META_MULTI_ROOT_KEY], + folderPicker: data._meta?.[SESSION_META_FOLDER_PICKER_KEY], + github: data._meta?.[SESSION_META_GITHUB_KEY], + git: data._meta?.[SESSION_META_GIT_KEY], + sourceControl: data._meta?.[SESSION_META_SOURCE_CONTROL_KEY], + artifacts: data._meta?.[SESSION_META_ARTIFACTS_KEY], + creationReference: data._meta?.[SESSION_META_CREATED_BY_SESSION_KEY], + chats: data.chats, + }); + }); + + test('falls back for every unusable row and hides a chat-backing row instead', async () => { + const outdated = encode(data); + const cases: Array<{ readonly expected: string; readonly mutate: (database: TestCatalogDatabase) => void }> = [ + { expected: 'fallback', mutate: database => database.catalog = undefined }, + { expected: 'chatBacking', mutate: database => database.catalog = { ...database.catalog!, isChatBacking: true } }, + { expected: 'fallback', mutate: database => database.catalog = { ...database.catalog!, session: AgentSession.uri('copilot', 'other').toString() } }, + { expected: 'fallback', mutate: database => database.catalog = { ...database.catalog!, provider: 'claude' } }, + { expected: 'fallback', mutate: database => database.catalog = { ...database.catalog!, payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION - 1, payload: outdated.payload } }, + { expected: 'fallback', mutate: database => database.catalog = { ...database.catalog!, payload: '{ not json' } }, + { expected: 'fallback', mutate: database => database.readError = new Error('read failed') }, + ]; + const actual: string[] = []; + for (const testCase of cases) { + const database = createDatabase(); + testCase.mutate(database); + const result = await new AgentHostCatalogListReader(database).read(registered); + actual.push(result.eligible ? 'eligible' : result.chatBacking ? 'chatBacking' : 'fallback'); + } + assert.deepStrictEqual(actual, cases.map(testCase => testCase.expected)); + }); + + test('hides a chat-backing payload even when the row marker disagrees', async () => { + const database = createDatabase({ ...data, isChatBacking: true }); + database.catalog = { ...database.catalog!, isChatBacking: false }; + + const result = await new AgentHostCatalogListReader(database).read(registered); + + assert.deepStrictEqual(result, { eligible: false, chatBacking: true }); + }); + + test('rejects a registry provider that does not match the session identity', async () => { + const result = await new AgentHostCatalogListReader(createDatabase()).read({ ...registered, provider: 'claude' }); + + assert.strictEqual(result.eligible, false); + assert.strictEqual(result.eligible === false && result.chatBacking, false); + }); + + test('reports a read failure with its error so the caller can log it', async () => { + const database = createDatabase(); + database.readError = new Error('read failed'); + + const result = await new AgentHostCatalogListReader(database).read(registered); + + assert.deepStrictEqual(result.eligible === false && !result.chatBacking ? result.error?.message : undefined, 'read failed'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts new file mode 100644 index 00000000000000..280d9d23f19871 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY } from '../../common/meta/agentDevContainerWorktreeMeta.js'; +import { SESSION_META_ARTIFACTS_KEY } from '../../common/sessionArtifacts.js'; +import { SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY } from '../../common/state/sessionState.js'; +import { + AGENT_HOST_CATALOG_ARTIFACT_LIMIT, + AGENT_HOST_CATALOG_CHILD_LIMIT, + AGENT_HOST_CATALOG_PAYLOAD_VERSION, + AgentHostCatalogData, + decodeAgentHostCatalogPayload, + encodeAgentHostCatalogPayload, + hashAgentHostCatalogPayload, + reviveAgentHostCatalogData, +} from '../../node/agentHostCatalogProjection.js'; + +function createData(): AgentHostCatalogData { + return { + modifiedTime: 1720000000000, + summary: 'Implement opaque catalog payload', + titleSource: 'user', + isRead: true, + isArchived: false, + project: { + uri: 'file:///workspace', + displayName: 'workspace', + }, + isChatBacking: false, + workingDirectories: ['file:///workspace', 'file:///workspace/secondary'], + changes: { + additions: 12, + deletions: 4, + files: 2, + }, + _meta: { + [SESSION_META_MULTI_ROOT_KEY]: { + workspaceFile: 'file:///workspace/project.code-workspace', + }, + [SESSION_META_FOLDER_PICKER_KEY]: { + hidden: true, + primary: 'file:///workspace', + }, + [SESSION_META_GITHUB_KEY]: { + owner: 'microsoft', + repo: 'vscode', + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + issueUrls: ['https://github.com/microsoft/vscode/issues/2'], + }, + [SESSION_META_GIT_KEY]: { + hasGitHubRemote: true, + branchName: 'feature/catalog', + incomingChanges: 2, + }, + [SESSION_META_SOURCE_CONTROL_KEY]: { + merge: { commit: '0123456789abcdef' }, + latestOutcome: 'merge', + }, + [SESSION_META_ARTIFACTS_KEY]: [{ + id: 'artifact-1', + type: 'pullRequest', + label: 'Catalog payload', + isArtifact: true, + link: 'https://github.com/microsoft/vscode/pull/1', + }], + [SESSION_META_CREATED_BY_SESSION_KEY]: { + session: 'agent-session://test/parent', + chat: 'agent-chat://test/parent/default', + turnId: 'turn-1', + }, + [SESSION_META_WORKSPACELESS_KEY]: true, + [SESSION_META_EHCLI_ADOPTABLE_KEY]: true, + [SESSION_META_EHCLI_ADOPTED_KEY]: true, + }, + chats: [{ + uri: 'agent-chat://test/session/default', + order: 0, + kind: 'default', + summary: 'Main', + titleSource: 'auto', + origin: { kind: 'default', metadata: { b: 2, a: 1 } }, + }, { + uri: 'agent-chat://test/session/peer', + order: 1, + kind: 'peer', + summary: 'Peer', + titleSource: 'agent', + origin: { kind: 'subagent' }, + }], + }; +} + +function encode(data: AgentHostCatalogData = createData()) { + const result = encodeAgentHostCatalogPayload(data); + assert.strictEqual(result.ok, true); + return result.value; +} + +suite('AgentHostCatalogProjection', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('derives the data type from validators and round trips canonical payload and hash', () => { + const typedData: AgentHostCatalogData = createData(); + const encoded = encode(typedData); + const decoded = decodeAgentHostCatalogPayload(encoded.payload); + + assert.deepStrictEqual({ + decoded, + payload: encoded.payload, + hash: encoded.payloadHash, + }, { + decoded: { + ok: true, + value: { data: typedData, payload: encoded.payload }, + }, + payload: '{"data":{"_meta":{"agentHost/createdBySession":{"chat":"agent-chat://test/parent/default","session":"agent-session://test/parent","turnId":"turn-1"},"agentHost/sessionArtifacts":[{"id":"artifact-1","isArtifact":true,"label":"Catalog payload","link":"https://github.com/microsoft/vscode/pull/1","type":"pullRequest"}],"ehcliAdoptable":true,"ehcliAdopted":true,"git":{"branchName":"feature/catalog","hasGitHubRemote":true,"incomingChanges":2},"github":{"issueUrls":["https://github.com/microsoft/vscode/issues/2"],"owner":"microsoft","pullRequestUrls":["https://github.com/microsoft/vscode/pull/1"],"repo":"vscode"},"multiRoot":{"workspaceFile":"file:///workspace/project.code-workspace"},"vscode.folderPicker":{"hidden":true,"primary":"file:///workspace"},"vscode.sourceControl":{"latestOutcome":"merge","merge":{"commit":"0123456789abcdef"}},"workspaceless":true},"changes":{"additions":12,"deletions":4,"files":2},"chats":[{"kind":"default","order":0,"origin":{"kind":"default","metadata":{"a":1,"b":2}},"summary":"Main","titleSource":"auto","uri":"agent-chat://test/session/default"},{"kind":"peer","order":1,"origin":{"kind":"subagent"},"summary":"Peer","titleSource":"agent","uri":"agent-chat://test/session/peer"}],"isArchived":false,"isChatBacking":false,"isRead":true,"modifiedTime":1720000000000,"project":{"displayName":"workspace","uri":"file:///workspace"},"summary":"Implement opaque catalog payload","titleSource":"user","workingDirectories":["file:///workspace","file:///workspace/secondary"]},"payloadVersion":1}', + hash: hashAgentHostCatalogPayload(encoded.payload), + }); + }); + + test('normalizes order and strips unknown properties without a SQL schema change', () => { + const source = JSON.parse(encode().payload); + source.futureEnvelopeField = 'ignored'; + source.data.futureOptionalPayloadField = { nested: true }; + source.data.project.futureProjectField = 'ignored'; + source.data._meta.futureMetaKey = { nested: true }; + source.data.chats.reverse(); + + const decoded = decodeAgentHostCatalogPayload(JSON.stringify(source)); + + assert.strictEqual(decoded.ok, true); + assert.deepStrictEqual({ + hasFutureEnvelopeField: decoded.value.payload.includes('futureEnvelopeField'), + hasFuturePayloadField: decoded.value.payload.includes('futureOptionalPayloadField'), + hasFutureProjectField: decoded.value.payload.includes('futureProjectField'), + hasFutureMetaKey: decoded.value.payload.includes('futureMetaKey'), + chatOrder: decoded.value.data.chats.map(chat => chat.order), + }, { + hasFutureEnvelopeField: false, + hasFuturePayloadField: false, + hasFutureProjectField: false, + hasFutureMetaKey: false, + chatOrder: [0, 1], + }); + + }); + + test('retains detached-head state and the newest bounded artifact suffix', () => { + const data = createData(); + const artifacts = Array.from({ length: AGENT_HOST_CATALOG_ARTIFACT_LIMIT + 2 }, (_, index) => ({ + id: `artifact-${index}`, + type: 'file' as const, + label: `Artifact ${index}`, + uri: index === AGENT_HOST_CATALOG_ARTIFACT_LIMIT + 1 ? `src/${index}.ts` : `file:///workspace/${index}`, + })); + const encoded = encode({ + ...data, + _meta: { + ...data._meta, + [SESSION_META_GIT_KEY]: { isDetachedHead: true }, + [SESSION_META_ARTIFACTS_KEY]: artifacts, + }, + }); + + assert.deepStrictEqual({ + git: encoded.data._meta?.[SESSION_META_GIT_KEY], + artifacts: encoded.data._meta?.[SESSION_META_ARTIFACTS_KEY], + }, { + git: { isDetachedHead: true }, + artifacts: artifacts.slice(-AGENT_HOST_CATALOG_ARTIFACT_LIMIT), + }); + }); + + test('preserves Dev Container worktree and pull request state metadata', () => { + const data = createData(); + const encoded = encode({ + ...data, + _meta: { + ...data._meta, + [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: { + version: 1, + handle: '00000000-0000-4000-8000-000000000001', + }, + [SESSION_META_GITHUB_KEY]: { + ...data._meta?.[SESSION_META_GITHUB_KEY], + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', + }, + }, + }); + + assert.deepStrictEqual({ + devContainerWorktree: encoded.data._meta?.[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY], + gitHub: encoded.data._meta?.[SESSION_META_GITHUB_KEY], + }, { + devContainerWorktree: { + version: 1, + handle: '00000000-0000-4000-8000-000000000001', + }, + gitHub: { + ...data._meta?.[SESSION_META_GITHUB_KEY], + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', + }, + }); + }); + + test('rejects missing fields, wrong types, bounds, duplicate children, and invalid URIs', () => { + const valid = JSON.parse(encode().payload); + const cases = [ + { ...valid, data: { ...valid.data, modifiedTime: undefined } }, + { ...valid, data: { ...valid.data, isRead: 'true' } }, + { ...valid, data: { ...valid.data, summary: 'x'.repeat(1025) } }, + { ...valid, data: { ...valid.data, workingDirectories: Array.from({ length: AGENT_HOST_CATALOG_CHILD_LIMIT + 1 }, (_, index) => `file:///workspace/${index}`) } }, + { ...valid, data: { ...valid.data, workingDirectories: ['file:///workspace', 'file:///workspace'] } }, + { ...valid, data: { ...valid.data, project: { uri: 'not a uri', displayName: 'invalid' } } }, + { ...valid, data: { ...valid.data, chats: [{ ...valid.data.chats[0], uri: 'not a uri' }] } }, + { ...valid, data: { ...valid.data, _meta: { [SESSION_META_GIT_KEY]: 'not an object' } } }, + { ...valid, data: { ...valid.data, _meta: { [SESSION_META_FOLDER_PICKER_KEY]: { hidden: false, primary: 'file:///workspace' } } } }, + ]; + + assert.deepStrictEqual(cases.map(value => { + const decoded = decodeAgentHostCatalogPayload(JSON.stringify(value)); + return decoded.ok ? 'ok' : decoded.reason; + }), [ + 'invalid', + 'invalid', + 'invalid', + 'invalid', + 'invalid', + 'invalid', + 'invalid', + 'invalid', + 'invalid', + ]); + }); + + test('classifies old payload versions as outdated before structural validation', () => { + const payload = JSON.parse(encode().payload); + payload.payloadVersion = AGENT_HOST_CATALOG_PAYLOAD_VERSION - 1; + payload.data = {}; + + const decoded = decodeAgentHostCatalogPayload(JSON.stringify(payload)); + + assert.deepStrictEqual(decoded.ok ? 'ok' : decoded.reason, 'outdated'); + }); + + test('revives every serialized URI in one place', () => { + const data = createData(); + const revived = reviveAgentHostCatalogData(data); + + assert.deepStrictEqual({ + project: revived.project?.uri.toString(), + workingDirectories: revived.workingDirectories.map(uri => uri.toString()), + chats: revived.chats.map(chat => chat.uri.toString()), + }, { + project: data.project?.uri, + workingDirectories: data.workingDirectories, + chats: data.chats.map(chat => chat.uri), + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts new file mode 100644 index 00000000000000..75ffa12363e31e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts @@ -0,0 +1,1005 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { Event } from '../../../../base/common/event.js'; +import { type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { ISessionDataService } from '../../common/sessionDataService.js'; +import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult, IAgentHostCatalogReconciliationOptions } from '../../node/agentHostCatalogReconciliationService.js'; +import { AgentHostCatalogSyncService } from '../../node/agentHostCatalogSyncService.js'; +import { AgentHostCatalogData, AGENT_HOST_CATALOG_PAYLOAD_VERSION, encodeAgentHostCatalogPayload } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope } from '../../node/agentHostDatabase.js'; +import type { IRegisteredSession } from '../../node/agentSessionRegistry.js'; +import type { IAgentHostStorageService } from '../../node/agentHostStorageService.js'; +import { TestSessionDatabase } from '../common/sessionTestHelpers.js'; + +function catalogData(summary: string): AgentHostCatalogData { + return { + modifiedTime: 1, + summary, + titleSource: 'user', + isRead: false, + isArchived: false, + workingDirectories: [], + chats: [{ + uri: `agenthost-chat:${summary}/default`, + order: 0, + kind: 'default', + summary, + titleSource: 'user', + }], + }; +} + +/** Reads the opaque payload the way a downstream reader would, without a SQL projection. */ +function summaryOf(payload: string): string { + return JSON.parse(payload).data.summary; +} + +function registered(name: string): IRegisteredSession { + return { + session: URI.parse(`agenthost:${name}`), + provider: 'copilot', + startTime: 1, + modifiedTime: 1, + external: false, + source: 'explicit', + }; +} + +class TestStorageService implements IAgentHostStorageService { + declare readonly _serviceBrand: undefined; + readonly onDidChange = Event.None; + readonly loadError = undefined; + private readonly _values = new Map(); + + get(key: string): T | undefined { + return this._values.get(key) as T | undefined; + } + + set(key: string, value: T): void { + this._values.set(key, value); + } + + async setAndFlush(key: string, value: T): Promise { + this.set(key, value); + } + + delete(key: string): void { + this._values.delete(key); + } + + async whenIdle(): Promise { } +} + +class RecordingCatalogDatabase extends AgentHostDatabase { + upsertCalls = 0; + failUpsert = false; + failUpsertCount = 0; + failMarkAll = 0; + markAllCalls = 0; + dirtyAfterUpsertCount = 0; + nonCanonicalPayloadReads = 0; + conflictingEnvelope: IAgentHostDatabaseSessionV2Envelope | undefined; + + constructor() { + super(':memory:'); + } + + override async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { + this.upsertCalls++; + if (this.failUpsert || this.failUpsertCount > 0) { + this.failUpsertCount = Math.max(0, this.failUpsertCount - 1); + throw new Error('central unavailable'); + } + if (this.conflictingEnvelope) { + const conflictingEnvelope = this.conflictingEnvelope; + this.conflictingEnvelope = undefined; + await super.upsertSessionV2(conflictingEnvelope, expectedSessionGeneration); + await super.markSessionV2PayloadDirty(envelope.session); + return 'conflict'; + } + const result = await super.upsertSessionV2(envelope, expectedSessionGeneration); + if (this.dirtyAfterUpsertCount > 0 && (result === 'applied' || result === 'replayed')) { + this.dirtyAfterUpsertCount--; + await super.markSessionV2PayloadDirty(envelope.session); + } + return result; + } + + override async markAllSessionsV2PayloadsDirty(): Promise { + this.markAllCalls++; + if (this.failMarkAll > 0) { + this.failMarkAll--; + throw new Error('dirty marker unavailable'); + } + return super.markAllSessionsV2PayloadsDirty(); + } + + override async getSessionV2(session: string): Promise { + const result = await super.getSessionV2(session); + if (result && this.nonCanonicalPayloadReads > 0) { + this.nonCanonicalPayloadReads--; + return { ...result, payload: ` ${result.payload}` }; + } + return result; + } +} + +class TestScheduler { + private readonly _entries: { readonly callback: () => void; readonly delay: number; active: boolean }[] = []; + + readonly schedule = (callback: () => void, delay: number): IDisposable => { + const entry = { callback, delay, active: true }; + this._entries.push(entry); + return { dispose: () => entry.active = false }; + }; + + get activeDelays(): readonly number[] { + return this._entries.filter(entry => entry.active).map(entry => entry.delay); + } + + run(delay: number): void { + const entry = this._entries.find(candidate => candidate.active && candidate.delay === delay); + assert.ok(entry, `No active ${delay}ms timer`); + entry.active = false; + entry.callback(); + } +} + +interface ITestHarness { + readonly central: RecordingCatalogDatabase; + readonly locals: Map; + readonly sync: AgentHostCatalogSyncService; + readonly getDatabaseOpenAttempts: () => number; + createService(resolveSource?: (session: IRegisteredSession) => Promise, options?: IAgentHostCatalogReconciliationOptions): AgentHostCatalogReconciliationService; +} + +suite('AgentHostCatalogReconciliationService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + async function createHarness(names: readonly string[], missing: ReadonlySet = new Set()): Promise { + const central = store.add(new RecordingCatalogDatabase()); + const sessions = names.map(registered); + for (const session of sessions) { + await central.registerSessionV2(session.session.toString(), { + provider: session.provider, + startTime: session.startTime, + source: session.source, + }, { checkTombstone: false }); + } + const locals = new Map(); + let databaseOpenAttempts = 0; + for (const session of sessions) { + if (!missing.has(session.session.toString())) { + locals.set(session.session.toString(), new TestSessionDatabase()); + } + } + const sessionDataService: ISessionDataService = { + _serviceBrand: undefined, + getSessionDataDir: session => URI.from({ scheme: Schemas.inMemory, path: `/session-data/${session.path}` }), + getSessionDataDirById: sessionId => URI.from({ scheme: Schemas.inMemory, path: `/session-data/${sessionId}` }), + openDatabase: session => reference(requiredLocal(locals, session)), + tryOpenDatabase: async session => { + databaseOpenAttempts++; + const database = locals.get(session.toString()); + return database ? reference(database) : undefined; + }, + deleteSessionData: async () => { }, + onWillDeleteSessionData: Event.None, + cleanupOrphanedData: async () => { }, + whenIdle: async () => { }, + }; + const storage = new TestStorageService(); + const sync = new AgentHostCatalogSyncService(sessionDataService, central, new NullLogService()); + return { + central, + locals, + sync, + getDatabaseOpenAttempts: () => databaseOpenAttempts, + createService: (resolveSource = async session => ({ + status: 'available', + request: { data: catalogData(session.session.path), legacyMetadata: { customTitle: session.session.path } }, + }), options) => store.add(new AgentHostCatalogReconciliationService( + central, + sync, + storage, + async () => sessions, + resolveSource, + new NullLogService(), + options, + )), + }; + } + + test('opens and re-projects dirty rows once, then skips clean rows before session.db', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + await harness.sync.synchronize(session.session, { data: catalogData('one'), legacyMetadata: { customTitle: 'one' } }); + harness.central.upsertCalls = 0; + let sourceResolutions = 0; + const service = harness.createService(async registeredSession => { + sourceResolutions++; + return { + status: 'available', + request: { data: catalogData(registeredSession.session.path), legacyMetadata: { customTitle: registeredSession.session.path } }, + }; + }); + + const first = await service.runPass(); + const firstDatabaseOpenAttempts = harness.getDatabaseOpenAttempts(); + const second = await service.runPass(); + + assert.deepStrictEqual({ + first: first.outcomes, + second: second.outcomes, + upsertCalls: harness.central.upsertCalls, + firstDatabaseOpenAttempts, + finalDatabaseOpenAttempts: harness.getDatabaseOpenAttempts(), + sourceResolutions, + }, { + first: [{ session: 'agenthost:one', status: 'skipped', reason: 'synchronized' }], + second: [], + upsertCalls: 0, + firstDatabaseOpenAttempts: 1, + finalDatabaseOpenAttempts: 1, + sourceResolutions: 1, + }); + }); + + test('replays a pending payload into missing sessions_v2 and clears the payload after acknowledgement', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + harness.central.failUpsert = true; + await harness.sync.synchronize(session.session, { data: catalogData('one'), legacyMetadata: { customTitle: 'one' } }); + const pending = await requiredLocal(harness.locals, session.session).getCatalogSyncSnapshot(); + harness.central.failUpsert = false; + + const service = harness.createService(); + const report = await service.runPass(); + const converged = await service.runPass(); + const acknowledged = await requiredLocal(harness.locals, session.session).getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + before: { state: pending?.state, hasPayload: pending?.payload !== undefined }, + outcomes: report.outcomes, + converged: converged.outcomes, + after: { state: acknowledged?.state, payload: acknowledged?.payload }, + catalogTitle: summaryOf((await harness.central.getSessionV2(session.session.toString()))!.payload), + }, { + before: { state: 'pending', hasPayload: true }, + outcomes: [{ session: 'agenthost:one', status: 'succeeded', reason: 'pendingReplayed', sourceRevision: 0 }], + converged: [], + after: { state: 'acknowledged', payload: undefined }, + catalogTitle: 'one', + }); + }); + + test('reports a transient central replay failure as pending and retries it', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + harness.central.failUpsertCount = 1; + await harness.sync.synchronize(session.session, { + data: catalogData('pending'), + legacyMetadata: { customTitle: 'pending' }, + }); + harness.central.failUpsertCount = 1; + const service = harness.createService(); + + const first = await service.runPass(); + const second = await service.runPass(); + + assert.deepStrictEqual({ + first: first.outcomes, + second: second.outcomes, + }, { + first: [{ session: 'agenthost:one', status: 'pending', reason: 'upsertFailed', sourceRevision: 0 }], + second: [{ session: 'agenthost:one', status: 'succeeded', reason: 'pendingReplayed', sourceRevision: 0 }], + }); + }); + + test('replays a compatible pending snapshot before resolving an unavailable provider', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + harness.central.failUpsert = true; + await harness.sync.synchronize(session.session, { data: catalogData('pending'), legacyMetadata: { customTitle: 'pending' } }); + harness.central.failUpsert = false; + let sourceResolutions = 0; + const report = await harness.createService(async () => { + sourceResolutions++; + return { status: 'providerUnavailable' }; + }).runPass(); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + sourceResolutions, + catalogTitle: summaryOf((await harness.central.getSessionV2(session.session.toString()))!.payload), + }, { + outcomes: [{ session: 'agenthost:one', status: 'succeeded', reason: 'pendingReplayed', sourceRevision: 0 }], + sourceResolutions: 0, + catalogTitle: 'pending', + }); + }); + + test('does not clear a dirty epoch added while replaying a pending payload', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + harness.central.failUpsert = true; + await harness.sync.synchronize(session.session, { data: catalogData('pending'), legacyMetadata: { customTitle: 'pending' } }); + harness.central.failUpsert = false; + harness.central.dirtyAfterUpsertCount = 1; + + const report = await harness.createService().runPass(); + const cached = await harness.central.getSessionV2(session.session.toString()); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + payloadDirty: cached?.payloadDirty, + }, { + outcomes: [{ session: 'agenthost:one', status: 'retry', reason: 'superseded' }], + payloadDirty: 3, + }); + }); + + test('replaces a non-canonical central payload before clearing its dirty marker', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + await harness.sync.synchronize(session.session, { data: catalogData('one'), legacyMetadata: { customTitle: 'one' } }); + harness.central.nonCanonicalPayloadReads = 3; + + const report = await harness.createService().runPass(); + const repaired = await harness.central.getSessionV2(session.session.toString()); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + payloadStartsWithWhitespace: repaired?.payload.startsWith(' '), + sourceRevision: repaired?.sourceRevision, + payloadDirty: repaired?.payloadDirty, + }, { + outcomes: [{ session: 'agenthost:one', status: 'succeeded', reason: 'synchronized', sourceRevision: 1 }], + payloadStartsWithWhitespace: false, + sourceRevision: 1, + payloadDirty: 0, + }); + }); + + test('runFullPass drains its initial dirty population once across bounded batches', async () => { + const harness = await createHarness(['one', 'two', 'three']); + const resolutions = new Map(); + const report = await harness.createService(async session => { + const key = session.session.toString(); + resolutions.set(key, (resolutions.get(key) ?? 0) + 1); + return key === 'agenthost:two' + ? { status: 'providerUnavailable' } + : { status: 'available', request: { data: catalogData(session.session.path), legacyMetadata: { customTitle: session.session.path } } }; + }, { batchSize: 1 }).runFullPass(); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + resolutions: [...resolutions], + }, { + outcomes: [ + { session: 'agenthost:one', status: 'succeeded', reason: 'synchronized', sourceRevision: 0 }, + { session: 'agenthost:three', status: 'succeeded', reason: 'synchronized', sourceRevision: 0 }, + { session: 'agenthost:two', status: 'retry', reason: 'providerUnavailable' }, + ], + resolutions: [ + ['agenthost:one', 1], + ['agenthost:three', 1], + ['agenthost:two', 1], + ], + }); + }); + + test('runFullPass drains joined schedule and runPass requests with one trailing pass', async () => { + const harness = await createHarness(['one']); + let firstSourceStarted!: () => void; + const firstStarted = new Promise(resolve => firstSourceStarted = resolve); + let releaseFirstSource!: () => void; + const firstSourceGate = new Promise(resolve => releaseFirstSource = resolve); + let sourceResolutions = 0; + const service = harness.createService(async () => { + sourceResolutions++; + if (sourceResolutions === 1) { + firstSourceStarted(); + await firstSourceGate; + } + return { status: 'providerUnavailable' }; + }); + + const fullPass = service.runFullPass(); + await firstStarted; + service.schedule(); + const joinedPass = service.runPass(); + releaseFirstSource(); + const [fullReport, joinedReport] = await Promise.all([fullPass, joinedPass]); + + assert.deepStrictEqual({ + fullOutcomes: fullReport.outcomes, + joinedOutcomes: joinedReport.outcomes, + sourceResolutions, + markAllCalls: harness.central.markAllCalls, + }, { + fullOutcomes: [ + { session: 'agenthost:one', status: 'retry', reason: 'providerUnavailable' }, + { session: 'agenthost:one', status: 'retry', reason: 'providerUnavailable' }, + ], + joinedOutcomes: [ + { session: 'agenthost:one', status: 'retry', reason: 'providerUnavailable' }, + { session: 'agenthost:one', status: 'retry', reason: 'providerUnavailable' }, + ], + sourceResolutions: 2, + markAllCalls: 1, + }); + }); + + test('whenIdle waits for a trailing pass requested during runFullPass', async () => { + const harness = await createHarness(['one']); + let firstSourceStarted!: () => void; + const firstStarted = new Promise(resolve => firstSourceStarted = resolve); + let releaseFirstSource!: () => void; + const firstSourceGate = new Promise(resolve => releaseFirstSource = resolve); + let trailingSourceStarted!: () => void; + const trailingStarted = new Promise(resolve => trailingSourceStarted = resolve); + let releaseTrailingSource!: () => void; + const trailingSourceGate = new Promise(resolve => releaseTrailingSource = resolve); + let sourceResolutions = 0; + const service = harness.createService(async () => { + sourceResolutions++; + if (sourceResolutions === 1) { + firstSourceStarted(); + await firstSourceGate; + } else { + trailingSourceStarted(); + await trailingSourceGate; + } + return { status: 'providerUnavailable' }; + }); + + const fullPass = service.runFullPass(); + await firstStarted; + service.schedule(); + let idleSettled = false; + const idle = service.whenIdle().then(() => idleSettled = true); + releaseFirstSource(); + await trailingStarted; + const settledBeforeTrailingRelease = idleSettled; + releaseTrailingSource(); + await Promise.all([fullPass, idle]); + + assert.deepStrictEqual({ + settledBeforeTrailingRelease, + idleSettled, + sourceResolutions, + markAllCalls: harness.central.markAllCalls, + }, { + settledBeforeTrailingRelease: false, + idleSettled: true, + sourceResolutions: 2, + markAllCalls: 1, + }); + }); + + test('periodically verifies clean rows when provider state has no dirty event', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + await harness.sync.synchronize(session.session, { data: catalogData('one'), legacyMetadata: { customTitle: 'one' } }); + let now = 0; + let sourceResolutions = 0; + const service = harness.createService(async () => { + sourceResolutions++; + return { status: 'available', request: { data: catalogData('one'), legacyMetadata: { customTitle: 'one' } } }; + }, { + fullVerificationIntervalMs: 100, + now: () => now, + }); + + await service.runPass(); + const clean = await service.runPass(); + now = 100; + const safetySweep = await service.runPass(); + + assert.deepStrictEqual({ + clean: clean.outcomes, + safetySweep: safetySweep.outcomes, + databaseOpenAttempts: harness.getDatabaseOpenAttempts(), + sourceResolutions, + }, { + clean: [], + safetySweep: [{ session: 'agenthost:one', status: 'skipped', reason: 'synchronized' }], + databaseOpenAttempts: 2, + sourceResolutions: 2, + }); + }); + + test('retries the startup dirty sweep after a transient central failure', async () => { + const harness = await createHarness(['one']); + harness.central.failMarkAll = 1; + const service = harness.createService(); + + await assert.rejects(service.runPass(), /dirty marker unavailable/); + const retried = await service.runPass(); + + assert.deepStrictEqual(retried.outcomes, [ + { session: 'agenthost:one', status: 'succeeded', reason: 'synchronized', sourceRevision: 0 }, + ]); + }); + + test('rebuilds from legacy/provider state and advances revision after an old-build mutation', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + await harness.sync.synchronize(session.session, { data: catalogData('one'), legacyMetadata: { customTitle: 'one' } }); + await requiredLocal(harness.locals, session.session).setMetadata('customTitle', 'old-title'); + + const report = await harness.createService(async () => ({ + status: 'available', + request: { data: catalogData('old-title'), legacyMetadata: { customTitle: 'old-title' } }, + })).runPass(); + const receipt = await requiredLocal(harness.locals, session.session).getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + title: summaryOf((await harness.central.getSessionV2(session.session.toString()))!.payload), + revision: receipt?.sourceRevision, + payload: receipt?.payload, + }, { + outcomes: [{ session: 'agenthost:one', status: 'succeeded', reason: 'synchronized', sourceRevision: 1 }], + title: 'old-title', + revision: 1, + payload: undefined, + }); + }); + + test('re-projects instead of failing when an older build left a pending snapshot in its own projection', async () => { + // A downgraded build writes the user's rename into the session database + // and leaves a pending snapshot this build cannot replay. The central + // row it could not update is still structurally valid, so nothing else + // would ever repair it. + const harness = await createHarness(['one']); + const session = registered('one'); + await harness.sync.synchronize(session.session, { data: catalogData('one'), legacyMetadata: { customTitle: 'one' } }); + const local = requiredLocal(harness.locals, session.session); + const acknowledged = await local.getCatalogSyncSnapshot(); + assert.ok(acknowledged); + await local.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'renamed-by-older-build' }, { + sessionGeneration: acknowledged.sessionGeneration, + sourceRevision: acknowledged.sourceRevision + 1, + projectionVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION + 3, + payload: '{"projectionVersion":4,"source":{"title":"renamed-by-older-build"}}', + payloadHash: 'older-build-hash', + state: 'pending', + }); + + const report = await harness.createService(async () => ({ + status: 'available', + request: { data: catalogData('renamed-by-older-build'), legacyMetadata: { customTitle: 'renamed-by-older-build' } }, + })).runPass(); + const receipt = await local.getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + title: summaryOf((await harness.central.getSessionV2(session.session.toString()))!.payload), + state: receipt?.state, + generation: receipt?.sessionGeneration === acknowledged.sessionGeneration, + }, { + outcomes: [{ session: 'agenthost:one', status: 'succeeded', reason: 'synchronized', sourceRevision: 2 }], + title: 'renamed-by-older-build', + state: 'acknowledged', + generation: true, + }); + }); + + test('adopts the current sessions_v2 generation when the local receipt is stale', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + await harness.sync.synchronize(session.session, { data: catalogData('one'), legacyMetadata: { customTitle: 'one' } }); + const current = await harness.central.getSessionV2(session.session.toString()); + assert.ok(current); + await harness.central.upsertSessionV2({ ...current, sessionGeneration: 'current', sourceRevision: current.sourceRevision + 1 }, current.sessionGeneration); + const local = requiredLocal(harness.locals, session.session); + + const report = await harness.createService(async () => ({ + status: 'available', + request: { data: catalogData('two'), legacyMetadata: { customTitle: 'two' } }, + })).runPass(); + + assert.deepStrictEqual({ + outcome: report.outcomes.at(-1), + generation: (await local.getCatalogSyncSnapshot())?.sessionGeneration, + }, { + outcome: { session: 'agenthost:one', status: 'succeeded', reason: 'synchronized', sourceRevision: 2 }, + generation: 'current', + }); + }); + + test('reconciles a missing session database through the central-only path', async () => { + const missing = new Set(['agenthost:missing']); + const harness = await createHarness(['missing'], missing); + + const report = await harness.createService().runPass(); + const catalog = await harness.central.getSessionV2('agenthost:missing'); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + summary: catalog && summaryOf(catalog.payload), + payloadDirty: catalog?.payloadDirty, + }, { + outcomes: [{ session: 'agenthost:missing', status: 'succeeded', reason: 'synchronized', sourceRevision: 0 }], + summary: 'missing', + payloadDirty: 0, + }); + }); + + test('provider-only reconciliation CAS-clears the observed dirty marker', async () => { + const missing = new Set(['agenthost:missing']); + const harness = await createHarness(['missing'], missing); + const session = registered('missing'); + await harness.sync.synchronizeMigrationWithFactory(session.session, async () => ({ + data: catalogData('old'), + legacyMetadata: {}, + })); + await harness.central.markSessionV2PayloadDirty(session.session.toString()); + + const report = await harness.createService(async () => ({ + status: 'available', + request: { data: catalogData('new'), legacyMetadata: {} }, + })).runPass(); + const catalog = await harness.central.getSessionV2(session.session.toString()); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + summary: catalog && summaryOf(catalog.payload), + payloadDirty: catalog?.payloadDirty, + }, { + outcomes: [{ session: 'agenthost:missing', status: 'succeeded', reason: 'synchronized', sourceRevision: 1 }], + summary: 'new', + payloadDirty: 0, + }); + }); + + test('provider-only reconciliation rechecks an incomplete dirty marker after source resolution', async () => { + const missing = new Set(['agenthost:missing']); + const harness = await createHarness(['missing'], missing); + const session = registered('missing'); + let title = 'old'; + let sourceStarted!: () => void; + const started = new Promise(resolve => sourceStarted = resolve); + let releaseSource!: () => void; + const sourceGate = new Promise(resolve => releaseSource = resolve); + const service = harness.createService(async () => { + sourceStarted(); + await sourceGate; + return { status: 'available', request: { data: catalogData(title), legacyMetadata: {} } }; + }); + + const firstPass = service.runPass(); + await started; + title = 'new'; + await harness.central.markSessionV2PayloadDirty(session.session.toString()); + releaseSource(); + const first = await firstPass; + const afterRace = await harness.central.getSessionV2(session.session.toString()); + const second = await service.runPass(); + const converged = await harness.central.getSessionV2(session.session.toString()); + + assert.deepStrictEqual({ + first: first.outcomes, + afterRace, + second: second.outcomes, + summary: converged && summaryOf(converged.payload), + payloadDirty: converged?.payloadDirty, + }, { + first: [{ session: 'agenthost:missing', status: 'retry', reason: 'superseded' }], + afterRace: undefined, + second: [{ session: 'agenthost:missing', status: 'succeeded', reason: 'synchronized', sourceRevision: 0 }], + summary: 'new', + payloadDirty: 0, + }); + }); + + test('provider-only reconciliation does not overwrite a conflict that dirties the observed receipt', async () => { + const missing = new Set(['agenthost:missing']); + const harness = await createHarness(['missing'], missing); + const session = registered('missing'); + await harness.sync.synchronizeMigrationWithFactory(session.session, async () => ({ + data: catalogData('old'), + legacyMetadata: {}, + })); + const current = await harness.central.getSessionV2(session.session.toString()); + assert.ok(current); + await harness.central.markSessionV2PayloadDirty(session.session.toString()); + const concurrent = encodeAgentHostCatalogPayload(catalogData('concurrent')); + assert.ok(concurrent.ok); + harness.central.conflictingEnvelope = { + ...current, + sourceRevision: current.sourceRevision + 1, + payload: concurrent.value.payload, + payloadHash: concurrent.value.payloadHash, + }; + + const report = await harness.createService(async () => ({ + status: 'available', + request: { data: catalogData('stale-repair'), legacyMetadata: {} }, + })).runPass(); + const catalog = await harness.central.getSessionV2(session.session.toString()); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + summary: catalog && summaryOf(catalog.payload), + payloadDirty: catalog?.payloadDirty, + }, { + outcomes: [{ session: 'agenthost:missing', status: 'retry', reason: 'superseded' }], + summary: 'concurrent', + payloadDirty: 3, + }); + }); + + test('keeps provider-unavailable payloads dirty without evicting the cached row', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + await harness.sync.synchronize(session.session, { data: catalogData('cached'), legacyMetadata: { customTitle: 'cached' } }); + const service = harness.createService(async () => ({ status: 'providerUnavailable' })); + + const first = await service.runPass(); + const second = await service.runPass(); + const cached = await harness.central.getSessionV2(session.session.toString()); + + assert.deepStrictEqual({ + first: first.outcomes, + second: second.outcomes, + databaseOpenAttempts: harness.getDatabaseOpenAttempts(), + cachedSummary: cached && summaryOf(cached.payload), + payloadDirty: cached?.payloadDirty, + }, { + first: [{ session: 'agenthost:one', status: 'retry', reason: 'providerUnavailable' }], + second: [{ session: 'agenthost:one', status: 'retry', reason: 'providerUnavailable' }], + databaseOpenAttempts: 2, + cachedSummary: 'cached', + payloadDirty: 3, + }); + }); + + test('runs the safety sweep even while another row remains permanently dirty', async () => { + const harness = await createHarness(['clean', 'stuck']); + for (const name of ['clean', 'stuck']) { + const session = registered(name); + await harness.sync.synchronize(session.session, { data: catalogData(name), legacyMetadata: { customTitle: name } }); + } + let now = 0; + const service = harness.createService(async session => session.session.path === 'stuck' + ? { status: 'providerUnavailable' } + : { status: 'available', request: { data: catalogData('clean'), legacyMetadata: { customTitle: 'clean' } } }, { + fullVerificationIntervalMs: 100, + now: () => now, + }); + + await service.runPass(); + now = 100; + const safetySweep = await service.runPass(); + + assert.deepStrictEqual(safetySweep.outcomes, [ + { session: 'agenthost:clean', status: 'skipped', reason: 'synchronized' }, + { session: 'agenthost:stuck', status: 'retry', reason: 'providerUnavailable' }, + ]); + }); + + test('schedule replaces a pending periodic timer with a prompt background repair', async () => { + const harness = await createHarness(['one']); + const scheduler = new TestScheduler(); + const service = harness.createService(undefined, { + backgroundDelayMs: 10, + intervalMs: 300, + schedule: scheduler.schedule, + }); + + service.start(); + await service.runPass(); + assert.deepStrictEqual(scheduler.activeDelays, [300]); + + service.schedule(); + + assert.deepStrictEqual(scheduler.activeDelays, [10]); + }); + + test('whenIdle drains scheduled work without re-dirtying clean rows', async () => { + const harness = await createHarness(['one']); + const scheduler = new TestScheduler(); + const service = harness.createService(undefined, { + backgroundDelayMs: 10, + intervalMs: 300, + schedule: scheduler.schedule, + }); + + service.schedule(); + await service.whenIdle(); + const databaseOpenAttemptsAfterInitialPass = harness.getDatabaseOpenAttempts(); + service.schedule(); + await service.whenIdle(); + + assert.deepStrictEqual({ + databaseOpenAttemptsAfterInitialPass, + finalDatabaseOpenAttempts: harness.getDatabaseOpenAttempts(), + activeDelays: scheduler.activeDelays, + }, { + databaseOpenAttemptsAfterInitialPass: 1, + finalDatabaseOpenAttempts: 1, + activeDelays: [300], + }); + }); + + test('scheduled start rearms periodic work after an in-flight direct pass', async () => { + const harness = await createHarness(['one']); + const scheduler = new TestScheduler(); + let sourceStarted!: () => void; + const started = new Promise(resolve => sourceStarted = resolve); + let releaseSource!: () => void; + const sourceGate = new Promise(resolve => releaseSource = resolve); + let sourceResolutions = 0; + const service = harness.createService(async session => { + sourceResolutions++; + if (sourceResolutions === 1) { + sourceStarted(); + await sourceGate; + } + return { status: 'available', request: { data: catalogData(session.session.path), legacyMetadata: { customTitle: session.session.path } } }; + }, { + intervalMs: 300, + schedule: scheduler.schedule, + }); + + const direct = service.runPass(); + await started; + service.start(); + releaseSource(); + await direct; + + assert.deepStrictEqual({ + sourceResolutions, + activeDelays: scheduler.activeDelays, + }, { + sourceResolutions: 1, + activeDelays: [300], + }); + }); + + test('uses the same ordinal comparator for ordering and cursor boundaries', async () => { + const harness = await createHarness(['a', 'B', 'b']); + const visited: string[] = []; + const service = harness.createService(async session => { + visited.push(session.session.toString()); + return { status: 'providerUnavailable' }; + }, { batchSize: 1 }); + + await service.runPass(); + await service.runPass(); + await service.runPass(); + + assert.deepStrictEqual(visited, ['agenthost:B', 'agenthost:a', 'agenthost:b']); + }); + + test('serializes source verification and repair behind an in-flight writer', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + let currentTitle = 'one'; + const service = harness.createService(async () => ({ + status: 'available', + request: { data: catalogData(currentTitle), legacyMetadata: { customTitle: currentTitle } }, + })); + await harness.sync.synchronize(session.session, { data: catalogData(currentTitle), legacyMetadata: { customTitle: currentTitle } }); + await service.runPass(); + + let writerStarted!: () => void; + const started = new Promise(resolve => writerStarted = resolve); + let releaseWriter!: () => void; + const writerGate = new Promise(resolve => releaseWriter = resolve); + harness.central.failUpsertCount = 1; + const writer = harness.sync.synchronizeWithFactory(session.session, async () => { + writerStarted(); + await writerGate; + currentTitle = 'new-title'; + return { data: catalogData(currentTitle), legacyMetadata: { customTitle: currentTitle } }; + }); + await started; + const repair = service.runPass(); + releaseWriter(); + + const [writerResult, repairResult] = await Promise.all([writer, repair]); + const dirty = await harness.central.getSessionV2(session.session.toString()); + const converged = await service.runPass(); + const cached = await harness.central.getSessionV2(session.session.toString()); + + assert.deepStrictEqual({ + writerResult, + repair: repairResult.outcomes, + dirtySummary: dirty && summaryOf(dirty.payload), + dirtyMarker: dirty?.payloadDirty, + converged: converged.outcomes, + cachedSummary: cached && summaryOf(cached.payload), + payloadDirty: cached?.payloadDirty, + }, { + writerResult: { status: 'pending', sourceRevision: 1, reason: 'upsertFailed' }, + repair: [{ session: 'agenthost:one', status: 'retry', reason: 'superseded' }], + dirtySummary: 'one', + dirtyMarker: 2, + converged: [{ session: 'agenthost:one', status: 'succeeded', reason: 'pendingReplayed', sourceRevision: 1 }], + cachedSummary: 'new-title', + payloadDirty: 0, + }); + }); + + test('does not clear an unobserved dirty epoch on an incomplete row', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + let currentTitle = 'old-title'; + let sourceStarted!: () => void; + const started = new Promise(resolve => sourceStarted = resolve); + let releaseSource!: () => void; + const sourceGate = new Promise(resolve => releaseSource = resolve); + const service = harness.createService(async () => { + sourceStarted(); + await sourceGate; + return { status: 'available', request: { data: catalogData(currentTitle), legacyMetadata: { customTitle: currentTitle } } }; + }); + const repair = service.runPass(); + await started; + + currentTitle = 'new-title'; + harness.central.failUpsertCount = 1; + const writer = harness.sync.synchronize(session.session, { + data: catalogData(currentTitle), + legacyMetadata: { customTitle: currentTitle }, + }); + releaseSource(); + const firstRepair = await repair; + const writerResult = await writer; + const dirty = await harness.central.getSessionV2(session.session.toString()); + const converged = await service.runPass(); + const cached = await harness.central.getSessionV2(session.session.toString()); + + assert.deepStrictEqual({ + writerResult, + firstRepair: firstRepair.outcomes, + dirtyMarker: dirty?.payloadDirty, + converged: converged.outcomes, + cachedSummary: cached && summaryOf(cached.payload), + payloadDirty: cached?.payloadDirty, + }, { + writerResult: { status: 'acknowledged', sourceRevision: 0 }, + firstRepair: [{ session: 'agenthost:one', status: 'pending', reason: 'upsertFailed', sourceRevision: 0 }], + dirtyMarker: 2, + converged: [{ session: 'agenthost:one', status: 'skipped', reason: 'synchronized' }], + cachedSummary: 'new-title', + payloadDirty: 0, + }); + }); + + test('does not resurrect a tombstoned session', async () => { + const harness = await createHarness(['one']); + await harness.central.tombstoneAndUnregisterSession('agenthost:one'); + + assert.deepStrictEqual((await harness.createService().runPass()).outcomes, [ + { session: 'agenthost:one', status: 'retry', reason: 'tombstoned' }, + ]); + }); +}); + +function requiredLocal(locals: Map, session: URI): TestSessionDatabase { + const database = locals.get(session.toString()); + if (!database) { + throw new Error(`Missing local database for ${session.toString()}`); + } + return database; +} + +function reference(database: TestSessionDatabase): IReference { + return { + object: database, + dispose: () => { }, + }; +} diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts new file mode 100644 index 00000000000000..b39825f6406ac4 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -0,0 +1,412 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { META_CHANGES_SUMMARY } from '../../common/agentHostChangesetService.js'; +import { META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; +import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY } from '../../common/meta/agentDevContainerWorktreeMeta.js'; +import { SessionArtifactType, SESSION_META_ARTIFACTS_KEY, withSessionArtifacts } from '../../common/sessionArtifacts.js'; +import { ChatOriginKind } from '../../common/state/protocol/state.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionSourceControlOutcome, SessionStatus, withSessionCreationReference, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless } from '../../common/state/sessionState.js'; +import { AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, encodeAgentHostCatalogPayload } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostCatalogSourceResolver, CHAT_BACKING_METADATA_KEY, ICatalogSourceState } from '../../node/agentHostCatalogSourceResolver.js'; +import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; +import { WORKTREE_META_REPOSITORY_ROOT } from '../../node/shared/worktreeIsolation.js'; + +const session = URI.parse('agenthost:catalog-source'); +const chat = 'agenthost-chat:catalog-source/default'; +const liveArtifact = { id: 'live-artifact', type: SessionArtifactType.Website, label: 'Live artifact', isArtifact: true, link: 'https://example.com/live' }; +const persistedArtifact = { id: 'persisted-artifact', type: SessionArtifactType.Issue, label: 'Persisted artifact', isArtifact: true, link: 'https://example.com/persisted' }; +const liveCreationReference = { session: 'agenthost:live-creator', chat: 'agenthost-chat:live-creator/default', turnId: 'live-turn' } as const; +const persistedCreationReference = { session: 'agenthost:persisted-creator', chat: 'agenthost-chat:persisted-creator/default', turnId: 'persisted-turn' } as const; +const liveGit = { branchName: 'live-branch', outgoingChanges: 2 }; +const persistedGit = { branchName: 'persisted-branch', outgoingChanges: 5 }; +const liveGitHub = { owner: 'live-owner', repo: 'live-repo' }; +const persistedGitHub = { owner: 'persisted-owner', repo: 'persisted-repo' }; +const liveSourceControl = { merge: { commit: 'live-commit' }, latestOutcome: SessionSourceControlOutcome.Merge }; +const persistedSourceControl = { latestOutcome: SessionSourceControlOutcome.PullRequest }; + +function sourceState(): ICatalogSourceState { + let meta = withSessionMultiRootMetadata(undefined, { workspaceFile: 'file:///live.code-workspace' }); + meta = withSessionFolderPickerDecision(meta, { hidden: false }); + meta = withSessionArtifacts(meta, [liveArtifact]); + meta = withSessionCreationReference(meta, liveCreationReference); + meta = withSessionGitHubState(meta, liveGitHub); + meta = withSessionGitState(meta, liveGit); + meta = withSessionSourceControlState(meta, liveSourceControl); + meta = withSessionWorkspaceless(meta, true); + meta = withSessionEhcliAdoptable(meta); + return { + modifiedTime: 123, + title: 'Live title', + status: SessionStatus.Idle, + project: { uri: 'file:///live-project', displayName: 'Live project' }, + workingDirectories: ['file:///live'], + changes: { additions: 1, deletions: 2, files: 3 }, + meta, + chats: [{ + uri: chat, + kind: 'default', + title: 'Live chat', + origin: { kind: ChatOriginKind.Fork, chat: 'agenthost-chat:source/default', turnId: 'turn-1' }, + }], + }; +} + +function persistedMetadata(): Readonly> { + return { + [SESSION_CUSTOM_TITLE_KEY]: 'Persisted title', + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + [AH_META_IS_READ_DB_KEY]: 'true', + [AH_META_IS_ARCHIVED_DB_KEY]: 'true', + [AH_META_WORKSPACELESS_DB_KEY]: 'false', + [AH_META_EHCLI_ADOPTED_DB_KEY]: 'true', + [SESSION_META_MULTI_ROOT_KEY]: JSON.stringify({ workspaceFile: 'file:///persisted.code-workspace' }), + [SESSION_META_FOLDER_PICKER_KEY]: JSON.stringify({ hidden: true, primary: 'file:///persisted' }), + [SESSION_ARTIFACTS_KEY]: JSON.stringify([persistedArtifact]), + [AH_META_CREATED_BY_SESSION_DB_KEY]: JSON.stringify(persistedCreationReference), + [META_GITHUB_STATE]: JSON.stringify(persistedGitHub), + [META_GIT_STATE]: JSON.stringify(persistedGit), + [META_SOURCE_CONTROL_STATE]: JSON.stringify(persistedSourceControl), + [META_CHANGES_SUMMARY]: JSON.stringify({ additions: 10, deletions: 20, files: 30 }), + [CHAT_BACKING_METADATA_KEY]: 'agenthost-chat:owner/peer', + [WORKTREE_META_REPOSITORY_ROOT]: 'file:///persisted-worktree', + [customChatTitleMetadataKey(chat)]: 'Persisted chat', + [customChatTitleSourceMetadataKey(chat)]: 'agent', + }; +} + +function createResolver(metadata: Readonly>, unpersistedBacking = false): Pick { + const resolver = new AgentHostCatalogSourceResolver({ + isUnpersistedChatBacking: () => unpersistedBacking, + worktreeProjectFromRepositoryRoot: root => root ? { uri: URI.parse(root), displayName: 'Persisted worktree' } : undefined, + }); + return { + buildCatalogSyncRequest: (session, state, overrides, preferPersisted, _database, fallbacks) => resolver.buildCatalogSyncRequest( + session, + state, + overrides, + preferPersisted, + { + object: { + getMetadataObject: async >(keys: T): Promise<{ [K in keyof T]: string | undefined }> => + Object.fromEntries(Object.keys(keys).map(key => [key, metadata[key]])) as { [K in keyof T]: string | undefined }, + }, + }, + fallbacks, + ), + }; +} + +suite('AgentHostCatalogSourceResolver', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('consumes the provided database reference and propagates metadata read failures', async () => { + const absent = new AgentHostCatalogSourceResolver({ + isUnpersistedChatBacking: () => false, + worktreeProjectFromRepositoryRoot: () => undefined, + }); + + const result = await absent.buildCatalogSyncRequest(session, sourceState(), {}, true, undefined); + const failing = new AgentHostCatalogSourceResolver({ + isUnpersistedChatBacking: () => false, + worktreeProjectFromRepositoryRoot: () => undefined, + }); + + await assert.rejects( + failing.buildCatalogSyncRequest(session, sourceState(), {}, true, { + object: { getMetadataObject: async () => { throw new Error('metadata read failed'); } }, + }), + /metadata read failed/, + ); + assert.strictEqual(result.data.summary, 'Live title'); + }); + + test('prefers the downgrade-compatible session title mirror', async () => { + const result = await createResolver(persistedMetadata()).buildCatalogSyncRequest(session, sourceState(), {}, true); + + assert.deepStrictEqual(result.data.chats, [{ + uri: chat, + order: 0, + kind: 'default', + summary: 'Persisted chat', + titleSource: 'agent', + origin: { kind: ChatOriginKind.Fork, chat: 'agenthost-chat:source/default', turnId: 'turn-1' }, + }]); + }); + + test('preserves inherited peer provenance in the cached catalog payload', async () => { + const peer = 'agenthost-chat:catalog-source/peer'; + const result = await createResolver({}).buildCatalogSyncRequest(session, { + ...sourceState(), + chats: [{ + uri: peer, + kind: 'peer', + origin: { kind: ChatOriginKind.User }, + inheritedTurnId: 'inherited-turn', + }], + }, {}, true, undefined); + + assert.deepStrictEqual(result.data.chats, [{ + uri: peer, + order: 0, + kind: 'peer', + summary: undefined, + titleSource: 'auto', + origin: { kind: ChatOriginKind.User }, + inheritedTurnId: 'inherited-turn', + }]); + }); + + test('reads mirrored chat metadata from the provided session database', async () => { + const chats = Array.from({ length: 10 }, (_, index) => ({ + uri: `agenthost-chat:catalog-source/peer-${index}`, + kind: 'peer' as const, + title: `Live ${index}`, + })); + const metadata = Object.fromEntries(chats.flatMap((chat, index) => [ + [customChatTitleMetadataKey(chat.uri), `Fallback ${index}`], + [customChatTitleSourceMetadataKey(chat.uri), 'user'], + ])); + const resolver = createResolver(metadata); + + const result = await resolver.buildCatalogSyncRequest(session, { + ...sourceState(), + chats, + }, {}, true); + + assert.deepStrictEqual({ + titles: result.data.chats.map(chat => chat.summary), + sources: result.data.chats.map(chat => chat.titleSource), + }, { + titles: chats.map((_, index) => `Fallback ${index}`), + sources: chats.map(() => 'user'), + }); + }); + + test('uses the live session title when no explicit session title exists', async () => { + const metadata = { ...persistedMetadata() }; + delete metadata[SESSION_CUSTOM_TITLE_KEY]; + delete metadata[SESSION_CUSTOM_TITLE_SOURCE_KEY]; + const result = await createResolver(metadata).buildCatalogSyncRequest(session, sourceState(), {}, true); + + assert.deepStrictEqual({ + summary: result.data.summary, + titleSource: result.data.titleSource, + }, { + summary: 'Live title', + titleSource: 'auto', + }); + }); + + test('projects persisted Dev Container worktree and pull request state metadata', async () => { + const devContainerWorktree = { + version: 1, + handle: '00000000-0000-4000-8000-000000000001', + } as const; + const gitHub = { + ...persistedGitHub, + pullRequestState: 'merged', + pullRequestStateUrl: 'https://github.com/microsoft/vscode/pull/1', + } as const; + const result = await createResolver({ + ...persistedMetadata(), + [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: JSON.stringify(devContainerWorktree), + [META_GITHUB_STATE]: JSON.stringify(gitHub), + }).buildCatalogSyncRequest(session, sourceState(), {}, true); + + assert.deepStrictEqual({ + devContainerWorktree: result.data._meta?.[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY], + gitHub: result.data._meta?.[SESSION_META_GITHUB_KEY], + persistedDevContainerWorktree: result.legacyMetadata[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY], + }, { + devContainerWorktree, + gitHub, + persistedDevContainerWorktree: JSON.stringify(devContainerWorktree), + }); + }); + + test('bounds derived summaries without changing source metadata and produces a stable payload', async () => { + const oversized = `${'x'.repeat(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT - 2)}😀tail`; + const metadata = { + ...persistedMetadata(), + [SESSION_CUSTOM_TITLE_KEY]: oversized, + [customChatTitleMetadataKey(chat)]: oversized, + }; + const resolver = createResolver(metadata); + const first = await resolver.buildCatalogSyncRequest(session, sourceState(), {}, true); + const second = await resolver.buildCatalogSyncRequest(session, sourceState(), {}, true); + const firstPayload = encodeAgentHostCatalogPayload(first.data); + const secondPayload = encodeAgentHostCatalogPayload(second.data); + + assert.deepStrictEqual({ + sessionSummary: first.data.summary, + sessionSummaryLength: first.data.summary?.length, + chatSummary: first.data.chats[0].summary, + chatSummaryLength: first.data.chats[0].summary?.length, + legacySessionTitle: first.legacyMetadata[SESSION_CUSTOM_TITLE_KEY], + payloadHash: firstPayload.ok ? firstPayload.value.payloadHash : firstPayload.error, + hashStable: firstPayload.ok && secondPayload.ok && firstPayload.value.payloadHash === secondPayload.value.payloadHash, + }, { + sessionSummary: `${'x'.repeat(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT - 2)}…`, + sessionSummaryLength: AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT - 1, + chatSummary: `${'x'.repeat(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT - 2)}…`, + chatSummaryLength: AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT - 1, + legacySessionTitle: oversized, + payloadHash: firstPayload.ok ? firstPayload.value.payloadHash : firstPayload.error, + hashStable: true, + }); + }); + + test('prefers live chat titles over stale chat-local metadata during live synchronization', async () => { + const result = await createResolver(persistedMetadata()).buildCatalogSyncRequest(session, sourceState(), {}, false); + + assert.strictEqual(result.data.chats[0].summary, 'Live chat'); + }); + + test('prefers live state while preserving persisted-only source and legacy metadata', async () => { + const metadata = persistedMetadata(); + const result = await createResolver(metadata).buildCatalogSyncRequest(session, sourceState(), { + [SESSION_CUSTOM_TITLE_KEY]: 'Override title', + [customChatTitleMetadataKey(chat)]: 'Override chat', + }, false); + + assert.deepStrictEqual(result, { + data: { + modifiedTime: 123, + summary: 'Override title', + titleSource: 'user', + isRead: false, + isArchived: false, + project: { uri: 'file:///persisted-worktree', displayName: 'Persisted worktree' }, + isChatBacking: true, + changes: { additions: 1, deletions: 2, files: 3 }, + _meta: { + [SESSION_META_MULTI_ROOT_KEY]: { workspaceFile: 'file:///live.code-workspace' }, + [SESSION_META_FOLDER_PICKER_KEY]: { hidden: false }, + [SESSION_META_GITHUB_KEY]: liveGitHub, + [SESSION_META_GIT_KEY]: liveGit, + [SESSION_META_SOURCE_CONTROL_KEY]: liveSourceControl, + [SESSION_META_ARTIFACTS_KEY]: [liveArtifact], + [SESSION_META_CREATED_BY_SESSION_KEY]: liveCreationReference, + [SESSION_META_WORKSPACELESS_KEY]: true, + [SESSION_META_EHCLI_ADOPTABLE_KEY]: true, + [SESSION_META_EHCLI_ADOPTED_KEY]: true, + }, + workingDirectories: ['file:///live'], + chats: [{ + uri: chat, + order: 0, + kind: 'default', + summary: 'Override chat', + titleSource: 'agent', + origin: { kind: ChatOriginKind.Fork, chat: 'agenthost-chat:source/default', turnId: 'turn-1' }, + }], + }, + legacyMetadata: { + [SESSION_CUSTOM_TITLE_KEY]: 'Override title', + [customChatTitleMetadataKey(chat)]: 'Override chat', + [AH_META_IS_READ_DB_KEY]: '', + [AH_META_IS_ARCHIVED_DB_KEY]: '', + [SESSION_META_MULTI_ROOT_KEY]: JSON.stringify({ workspaceFile: 'file:///live.code-workspace' }), + [SESSION_META_FOLDER_PICKER_KEY]: JSON.stringify({ hidden: false }), + [SESSION_ARTIFACTS_KEY]: JSON.stringify([liveArtifact]), + [AH_META_CREATED_BY_SESSION_DB_KEY]: JSON.stringify(liveCreationReference), + [AH_META_WORKSPACELESS_DB_KEY]: 'true', + [CHAT_BACKING_METADATA_KEY]: 'agenthost-chat:owner/peer', + [WORKTREE_META_REPOSITORY_ROOT]: 'file:///persisted-worktree', + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + [META_GITHUB_STATE]: JSON.stringify(liveGitHub), + [META_SOURCE_CONTROL_STATE]: JSON.stringify(liveSourceControl), + [META_GIT_STATE]: JSON.stringify(liveGit), + [META_CHANGES_SUMMARY]: JSON.stringify({ additions: 1, deletions: 2, files: 3 }), + }, + }); + }); + + test('prefers persisted list metadata with live git precedence', async () => { + const result = await createResolver(persistedMetadata()).buildCatalogSyncRequest(session, sourceState(), {}, true); + + assert.deepStrictEqual(result, { + data: { + modifiedTime: 123, + summary: 'Persisted title', + titleSource: 'user', + isRead: true, + isArchived: true, + project: { uri: 'file:///persisted-worktree', displayName: 'Persisted worktree' }, + isChatBacking: true, + changes: { additions: 10, deletions: 20, files: 30 }, + _meta: { + [SESSION_META_MULTI_ROOT_KEY]: { workspaceFile: 'file:///persisted.code-workspace' }, + [SESSION_META_FOLDER_PICKER_KEY]: { hidden: true, primary: 'file:///persisted' }, + [SESSION_META_GITHUB_KEY]: persistedGitHub, + [SESSION_META_GIT_KEY]: liveGit, + [SESSION_META_SOURCE_CONTROL_KEY]: { merge: undefined, ...persistedSourceControl }, + [SESSION_META_ARTIFACTS_KEY]: [persistedArtifact], + [SESSION_META_CREATED_BY_SESSION_KEY]: persistedCreationReference, + [SESSION_META_EHCLI_ADOPTABLE_KEY]: true, + [SESSION_META_EHCLI_ADOPTED_KEY]: true, + }, + workingDirectories: ['file:///live'], + chats: [{ + uri: chat, + order: 0, + kind: 'default', + summary: 'Persisted chat', + titleSource: 'agent', + origin: { kind: ChatOriginKind.Fork, chat: 'agenthost-chat:source/default', turnId: 'turn-1' }, + }], + }, + legacyMetadata: { + [AH_META_IS_READ_DB_KEY]: 'true', + [AH_META_IS_ARCHIVED_DB_KEY]: 'true', + [SESSION_META_MULTI_ROOT_KEY]: JSON.stringify({ workspaceFile: 'file:///persisted.code-workspace' }), + [SESSION_META_FOLDER_PICKER_KEY]: JSON.stringify({ hidden: true, primary: 'file:///persisted' }), + [SESSION_ARTIFACTS_KEY]: JSON.stringify([persistedArtifact]), + [AH_META_CREATED_BY_SESSION_DB_KEY]: JSON.stringify(persistedCreationReference), + [AH_META_WORKSPACELESS_DB_KEY]: 'false', + [CHAT_BACKING_METADATA_KEY]: 'agenthost-chat:owner/peer', + [WORKTREE_META_REPOSITORY_ROOT]: 'file:///persisted-worktree', + [SESSION_CUSTOM_TITLE_KEY]: 'Persisted title', + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + [META_GITHUB_STATE]: JSON.stringify(persistedGitHub), + [META_SOURCE_CONTROL_STATE]: JSON.stringify(persistedSourceControl), + [META_GIT_STATE]: JSON.stringify(liveGit), + [META_CHANGES_SUMMARY]: JSON.stringify({ additions: 10, deletions: 20, files: 30 }), + }, + }); + }); + + test('omits malformed persisted changes metadata', async () => { + const result = await createResolver({ + ...persistedMetadata(), + [META_CHANGES_SUMMARY]: JSON.stringify({ additions: 'many', files: 1 }), + }).buildCatalogSyncRequest(session, sourceState(), {}, true); + + assert.strictEqual(result.data.changes, undefined); + }); + + test('re-projects persisted sources to the identical canonical payload hash', async () => { + const state = sourceState(); + const liveRequest = await createResolver({}).buildCatalogSyncRequest(session, state, {}, false); + const stored = encodeAgentHostCatalogPayload(liveRequest.data); + assert.strictEqual(stored.ok, true); + + const reprojectedRequest = await createResolver(liveRequest.legacyMetadata).buildCatalogSyncRequest(session, state, {}, true); + const reprojected = encodeAgentHostCatalogPayload(reprojectedRequest.data); + assert.strictEqual(reprojected.ok, true); + + assert.deepStrictEqual({ + payload: reprojected.value.payload, + payloadHash: reprojected.value.payloadHash, + }, { + payload: stored.value.payload, + payloadHash: stored.value.payloadHash, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts new file mode 100644 index 00000000000000..136c351ba7e20d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts @@ -0,0 +1,641 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, SessionCatalogSyncWriteResult } from '../../common/sessionDataService.js'; +import { META_GIT_STATE } from '../../common/agentHostGitStateService.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, AgentHostCatalogData, encodeAgentHostCatalogPayload } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostCatalogSyncService } from '../../node/agentHostCatalogSyncService.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabaseSessionV2Envelope } from '../../node/agentHostDatabase.js'; +import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; + +const session = URI.parse('agenthost:test-session'); + +function data(summary: string, chatSummary = summary): AgentHostCatalogData { + return { + modifiedTime: 1, + summary, + titleSource: 'user', + isRead: false, + isArchived: false, + workingDirectories: [], + chats: [{ + uri: 'agenthost-chat:test-session/default', + order: 0, + kind: 'default', + summary: chatSummary, + titleSource: 'user', + }], + }; +} + +/** Reads the opaque payload the way a downstream reader would, without a SQL projection. */ +function summaryOf(payload: string): string { + return JSON.parse(payload).data.summary; +} + +class RecordingSessionDatabase extends TestSessionDatabase { + readonly calls: string[] = []; + readonly writes: Array<{ readonly metadata: Readonly>; readonly title: string; readonly chatTitle: string }> = []; + failLocalWrite = false; + blockFirstWrite: Promise | undefined; + + constructor(private readonly order?: string[]) { + super(); + } + + override async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + const persisted = JSON.parse(snapshot.payload).data; + this.calls.push(`local:${snapshot.sourceRevision}:${persisted.summary}`); + this.writes.push({ metadata: { ...values }, title: persisted.summary, chatTitle: persisted.chats[0].summary }); + this.order?.push('local'); + if (this.failLocalWrite) { + throw new Error('local write failed'); + } + if (this.blockFirstWrite) { + const blocker = this.blockFirstWrite; + this.blockFirstWrite = undefined; + await blocker; + } + return super.setMetadataValuesAndCatalogSyncSnapshot(values, snapshot); + } + + override async transitionMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, expectedSessionGeneration: string, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + this.calls.push(`transition:${expectedSessionGeneration}:${snapshot.sessionGeneration}`); + return super.transitionMetadataValuesAndCatalogSyncSnapshot(values, expectedSessionGeneration, snapshot); + } + + override async acknowledgeCatalogSyncSnapshot(acknowledgement: ISessionCatalogSyncAcknowledgement): Promise { + this.calls.push(`ack:${acknowledgement.sourceRevision}`); + this.order?.push('ack'); + return super.acknowledgeCatalogSyncSnapshot(acknowledgement); + } +} + +class RecordingCatalogDatabase extends AgentHostDatabase { + readonly calls: string[] = []; + getError: Error | undefined; + upsertError: Error | undefined; + upsertResult: AgentHostDatabaseSessionV2UpsertResult | undefined; + seedConcurrentGeneration: string | undefined; + + constructor(private readonly order?: string[]) { + super(':memory:'); + } + + override async getSessionV2(session: string) { + this.calls.push('get'); + this.order?.push('get'); + if (this.getError) { + throw this.getError; + } + return super.getSessionV2(session); + } + + override async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { + this.calls.push(`upsert:${envelope.sourceRevision}:${summaryOf(envelope.payload)}`); + this.order?.push('upsert'); + if (this.upsertError) { + throw this.upsertError; + } + if (this.seedConcurrentGeneration) { + const generation = this.seedConcurrentGeneration; + this.seedConcurrentGeneration = undefined; + await super.upsertSessionV2({ ...envelope, sessionGeneration: generation }, expectedSessionGeneration); + return 'generationMismatch'; + } + return this.upsertResult ?? super.upsertSessionV2(envelope, expectedSessionGeneration); + } +} + +suite('AgentHostCatalogSyncService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + async function createHarness(order?: string[]) { + const local = new RecordingSessionDatabase(order); + const central = store.add(new RecordingCatalogDatabase(order)); + await central.registerSessionV2(session.toString(), { + provider: 'copilotcli', + startTime: 1, + source: 'explicit', + }, { checkTombstone: false }); + return { + local, + central, + service: new AgentHostCatalogSyncService(createSessionDataService(local), central, new NullLogService()), + }; + } + + test('writes legacy metadata and pending receipt before sessions_v2, then clears payload on exact acknowledgement', async () => { + const order: string[] = []; + const { local, central, service } = await createHarness(order); + + const result = await service.synchronize(session, { data: data('one'), legacyMetadata: { customTitle: 'one' } }); + const snapshot = await local.getCatalogSyncSnapshot(); + const catalog = await central.getSessionV2(session.toString()); + + assert.deepStrictEqual({ + result, + order: order.filter(call => call !== 'get'), + localCalls: local.calls, + title: await local.getMetadata('customTitle'), + snapshot, + catalogTitle: catalog && summaryOf(catalog.payload), + payloadDirty: catalog?.payloadDirty, + receiptMatchesCatalog: snapshot?.sessionGeneration === catalog?.sessionGeneration + && snapshot?.sourceRevision === catalog?.sourceRevision + && snapshot?.projectionVersion === catalog?.payloadVersion + && snapshot?.payloadHash === catalog?.payloadHash, + }, { + result: { status: 'acknowledged', sourceRevision: 0 }, + order: ['local', 'upsert', 'ack'], + localCalls: ['local:0:one', 'ack:0'], + title: 'one', + snapshot: { + sessionGeneration: snapshot?.sessionGeneration, + sourceRevision: 0, + projectionVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + payload: undefined, + payloadHash: snapshot?.payloadHash, + acknowledgedHash: snapshot?.payloadHash, + state: 'acknowledged', + }, + catalogTitle: 'one', + payloadDirty: 2, + receiptMatchesCatalog: true, + }); + }); + + test('migration writes only the central catalog when the local database is absent', async () => { + const central = store.add(new RecordingCatalogDatabase()); + await central.registerSessionV2(session.toString(), { + provider: 'copilotcli', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + let opens = 0; + let probes = 0; + const sessionDataService = { + ...createSessionDataService(), + openDatabase: () => { + opens++; + throw new Error('must not create a database'); + }, + tryOpenDatabase: async () => { + probes++; + return undefined; + }, + }; + const service = new AgentHostCatalogSyncService(sessionDataService, central, new NullLogService()); + + const result = await service.synchronizeMigrationWithFactory(session, async () => ({ + data: data('migrated'), + legacyMetadata: { customTitle: 'migrated' }, + })); + + assert.deepStrictEqual({ + result, + opens, + probes, + title: summaryOf((await central.getSessionV2(session.toString()))!.payload), + }, { + result: { status: 'acknowledged', sourceRevision: 0 }, + opens: 0, + probes: 1, + title: 'migrated', + }); + }); + + test('central-only migration retries a same-revision conflict instead of acknowledging the loser', async () => { + class ConflictingCatalogDatabase extends RecordingCatalogDatabase { + private conflicted = false; + + override async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedGeneration: string | undefined): Promise { + if (!this.conflicted) { + this.conflicted = true; + const concurrent = encodeAgentHostCatalogPayload(data('concurrent')); + if (!concurrent.ok) { + throw new Error(concurrent.error); + } + await super.upsertSessionV2({ + ...envelope, + payload: concurrent.value.payload, + payloadHash: concurrent.value.payloadHash, + }, expectedGeneration); + return 'conflict'; + } + return super.upsertSessionV2(envelope, expectedGeneration); + } + } + const central = store.add(new ConflictingCatalogDatabase()); + await central.registerSessionV2(session.toString(), { + provider: 'copilotcli', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + const service = new AgentHostCatalogSyncService({ + ...createSessionDataService(), + tryOpenDatabase: async () => undefined, + }, central, new NullLogService()); + + const result = await service.synchronizeMigrationWithFactory(session, async () => ({ data: data('migration'), legacyMetadata: {} })); + const catalog = await central.getSessionV2(session.toString()); + + assert.deepStrictEqual({ + result, + title: catalog && summaryOf(catalog.payload), + upserts: central.calls.filter(call => call.startsWith('upsert')).length, + }, { + result: { status: 'acknowledged', sourceRevision: 1 }, + title: 'migration', + upserts: 2, + }); + }); + + test('central-only migration reports transient central failures as pending', async () => { + const central = store.add(new RecordingCatalogDatabase()); + await central.registerSessionV2(session.toString(), { + provider: 'copilotcli', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + central.upsertError = new Error('central unavailable'); + const service = new AgentHostCatalogSyncService({ + ...createSessionDataService(), + tryOpenDatabase: async () => undefined, + }, central, new NullLogService()); + + assert.deepStrictEqual( + await service.synchronizeMigrationWithFactory(session, async () => ({ data: data('migration'), legacyMetadata: {} })), + { status: 'pending', sourceRevision: 0, reason: 'upsertFailed' }, + ); + }); + + test('migration uses coordinated local-first synchronization when the database exists', async () => { + const { local, central, service } = await createHarness(); + + const result = await service.synchronizeMigrationWithFactory(session, async () => ({ + data: data('migrated'), + legacyMetadata: { customTitle: 'migrated' }, + })); + + assert.deepStrictEqual({ + result, + localCalls: local.calls, + title: await local.getMetadata('customTitle'), + receiptState: (await local.getCatalogSyncSnapshot())?.state, + centralTitle: summaryOf((await central.getSessionV2(session.toString()))!.payload), + }, { + result: { status: 'acknowledged', sourceRevision: 0 }, + localCalls: ['local:0:migrated', 'ack:0'], + title: 'migrated', + receiptState: 'acknowledged', + centralTitle: 'migrated', + }); + }); + + test('migration propagates local database probe failures', async () => { + const central = store.add(new RecordingCatalogDatabase()); + await central.registerSessionV2(session.toString(), { + provider: 'copilotcli', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + const service = new AgentHostCatalogSyncService({ + ...createSessionDataService(), + tryOpenDatabase: async () => { throw new Error('probe failed'); }, + }, central, new NullLogService()); + + await assert.rejects( + service.synchronizeMigrationWithFactory(session, async () => ({ data: data('migrated'), legacyMetadata: {} })), + /probe failed/, + ); + assert.deepStrictEqual(await central.getSessionV2(session.toString()), undefined); + }); + + test('migration does not bypass a concurrent tombstone', async () => { + const central = store.add(new RecordingCatalogDatabase()); + await central.registerSessionV2(session.toString(), { + provider: 'copilotcli', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + await central.tombstoneAndUnregisterSession(session.toString()); + const service = new AgentHostCatalogSyncService({ + ...createSessionDataService(), + tryOpenDatabase: async () => undefined, + }, central, new NullLogService()); + + const result = await service.synchronizeMigrationWithFactory(session, async () => ({ data: data('migrated'), legacyMetadata: {} })); + + assert.deepStrictEqual({ + result, + catalog: await central.getSessionV2(session.toString()), + }, { + result: { status: 'pending', sourceRevision: 0, reason: 'tombstoned' }, + catalog: undefined, + }); + }); + + test('migration verifies and replaces a concurrent generation before acknowledging it', async () => { + class RacingCatalogDatabase extends RecordingCatalogDatabase { + private raced = false; + + override async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedGeneration: string | undefined): Promise { + if (!this.raced) { + this.raced = true; + const live = encodeAgentHostCatalogPayload(data('live adoption')); + if (!live.ok) { + throw new Error(live.error); + } + await super.upsertSessionV2({ + ...envelope, + sessionGeneration: 'live-generation', + payload: live.value.payload, + payloadHash: live.value.payloadHash, + }, expectedGeneration); + return 'generationMismatch'; + } + return super.upsertSessionV2(envelope, expectedGeneration); + } + } + const central = store.add(new RacingCatalogDatabase()); + await central.registerSessionV2(session.toString(), { + provider: 'copilotcli', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + const service = new AgentHostCatalogSyncService({ + ...createSessionDataService(), + tryOpenDatabase: async () => undefined, + }, central, new NullLogService()); + + const result = await service.synchronizeMigrationWithFactory(session, async () => ({ data: data('stale migration'), legacyMetadata: {} })); + const winner = await central.getSessionV2(session.toString()); + + assert.deepStrictEqual({ + result, + generation: winner?.sessionGeneration, + title: winner && summaryOf(winner.payload), + }, { + result: { status: 'acknowledged', sourceRevision: 1 }, + generation: 'live-generation', + title: 'stale migration', + }); + }); + + test('does not write sessions_v2 when the local transaction fails', async () => { + const { local, central, service } = await createHarness(); + local.failLocalWrite = true; + + await assert.rejects(service.synchronize(session, { data: data('one'), legacyMetadata: { customTitle: 'one' } }), /local write failed/); + assert.deepStrictEqual(central.calls, ['get']); + }); + + test('retains the pending payload when the central upsert fails', async () => { + const { local, central, service } = await createHarness(); + central.upsertError = new Error('central unavailable'); + + const result = await service.synchronize(session, { data: data('one'), legacyMetadata: { customTitle: 'one' } }); + const snapshot = await local.getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + result, + state: snapshot?.state, + hasPayload: snapshot?.payload !== undefined, + title: await local.getMetadata('customTitle'), + }, { + result: { status: 'pending', sourceRevision: 0, reason: 'upsertFailed' }, + state: 'pending', + hasPayload: true, + title: 'one', + }); + }); + + test('replays an acknowledged exact receipt without rewriting sessions_v2', async () => { + const { local, central, service } = await createHarness(); + const request = { data: data('one'), legacyMetadata: { customTitle: 'one' } }; + + const first = await service.synchronize(session, request); + const callsAfterFirst = central.calls.length; + const second = await service.synchronize(session, request); + + assert.deepStrictEqual({ + first, + second, + secondCentralCalls: central.calls.slice(callsAfterFirst), + localCalls: local.calls, + payload: (await local.getCatalogSyncSnapshot())?.payload, + }, { + first: { status: 'acknowledged', sourceRevision: 0 }, + second: { status: 'acknowledged', sourceRevision: 0 }, + secondCentralCalls: ['get'], + localCalls: ['local:0:one', 'ack:0', 'local:0:one'], + payload: undefined, + }); + }); + + test('advances the revision when legacy metadata changes without changing the projection hash', async () => { + const { local, service } = await createHarness(); + const catalogData = data('one'); + + await service.synchronize(session, { + data: catalogData, + legacyMetadata: { customTitle: 'one', [META_GIT_STATE]: '{"branch":"first"}' }, + }); + const first = await local.getCatalogSyncSnapshot(); + const result = await service.synchronize(session, { + data: catalogData, + legacyMetadata: { customTitle: 'one', [META_GIT_STATE]: '{"branch":"second"}' }, + }); + const second = await local.getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + result, + hashUnchanged: first?.payloadHash === second?.payloadHash, + revision: second?.sourceRevision, + payload: second?.payload, + gitState: await local.getMetadata(META_GIT_STATE), + }, { + result: { status: 'acknowledged', sourceRevision: 1 }, + hashUnchanged: true, + revision: 1, + payload: undefined, + gitState: '{"branch":"second"}', + }); + }); + + test('advances changed content beyond a newer local pending revision after central failure', async () => { + const { local, central, service } = await createHarness(); + await service.synchronize(session, { data: data('H0'), legacyMetadata: { customTitle: 'H0' } }); + central.upsertError = new Error('central unavailable'); + const failed = await service.synchronize(session, { data: data('H1'), legacyMetadata: { customTitle: 'H1' } }); + const pending = await local.getCatalogSyncSnapshot(); + central.upsertError = undefined; + + const recovered = await service.synchronize(session, { data: data('H2'), legacyMetadata: { customTitle: 'H2' } }); + const acknowledged = await local.getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + failed, + pending: { revision: pending?.sourceRevision, state: pending?.state, hasPayload: pending?.payload !== undefined }, + recovered, + acknowledged: { revision: acknowledged?.sourceRevision, state: acknowledged?.state, payload: acknowledged?.payload }, + central: { + revision: (await central.getSessionV2(session.toString()))?.sourceRevision, + title: summaryOf((await central.getSessionV2(session.toString()))!.payload), + }, + legacyTitle: await local.getMetadata('customTitle'), + }, { + failed: { status: 'pending', sourceRevision: 1, reason: 'upsertFailed' }, + pending: { revision: 1, state: 'pending', hasPayload: true }, + recovered: { status: 'acknowledged', sourceRevision: 2 }, + acknowledged: { revision: 2, state: 'acknowledged', payload: undefined }, + central: { revision: 2, title: 'H2' }, + legacyTitle: 'H2', + }); + }); + + test('advances pending content while getSessionV2 is unavailable and later converges without rejection', async () => { + const { local, central, service } = await createHarness(); + await service.synchronize(session, { data: data('H0'), legacyMetadata: { customTitle: 'H0' } }); + central.getError = new Error('central read unavailable'); + + const first = await service.synchronize(session, { data: data('H1'), legacyMetadata: { customTitle: 'H1' } }); + const second = await service.synchronize(session, { data: data('H2'), legacyMetadata: { customTitle: 'H2' } }); + const pending = await local.getCatalogSyncSnapshot(); + central.getError = undefined; + const recovered = await service.synchronize(session, { data: data('H2'), legacyMetadata: { customTitle: 'H2' } }); + + assert.deepStrictEqual({ + first, + second, + pending: { revision: pending?.sourceRevision, state: pending?.state, hasPayload: pending?.payload !== undefined }, + recovered, + central: { + revision: (await central.getSessionV2(session.toString()))?.sourceRevision, + title: summaryOf((await central.getSessionV2(session.toString()))!.payload), + }, + payload: (await local.getCatalogSyncSnapshot())?.payload, + }, { + first: { status: 'pending', sourceRevision: 1, reason: 'upsertFailed' }, + second: { status: 'pending', sourceRevision: 2, reason: 'upsertFailed' }, + pending: { revision: 2, state: 'pending', hasPayload: true }, + recovered: { status: 'acknowledged', sourceRevision: 2 }, + central: { revision: 2, title: 'H2' }, + payload: undefined, + }); + }); + + test('adopts the winning generation after a concurrent first writer', async () => { + const { local, central, service } = await createHarness(); + central.seedConcurrentGeneration = 'winner'; + + const result = await service.synchronize(session, { data: data('one'), legacyMetadata: {} }); + const snapshot = await local.getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + result, + generation: snapshot?.sessionGeneration, + localCalls: local.calls.map(call => call.startsWith('transition:') ? 'transition' : call), + centralCalls: central.calls, + }, { + result: { status: 'acknowledged', sourceRevision: 0 }, + generation: 'winner', + localCalls: ['local:0:one', 'transition', 'ack:0'], + centralCalls: ['get', 'upsert:0:one', 'get', 'upsert:0:one'], + }); + }); + + test('delete and recreate uses a new session generation', async () => { + const { local, central, service } = await createHarness(); + await service.synchronize(session, { data: data('one'), legacyMetadata: {} }); + const firstGeneration = (await local.getCatalogSyncSnapshot())?.sessionGeneration; + await central.tombstoneAndUnregisterSession(session.toString()); + await central.clearSessionTombstone(session.toString()); + await central.registerSessionV2(session.toString(), { + provider: 'copilotcli', + startTime: 2, + source: 'explicit', + }, { checkTombstone: false }); + + const result = await service.synchronize(session, { data: data('two'), legacyMetadata: {} }); + const secondGeneration = (await local.getCatalogSyncSnapshot())?.sessionGeneration; + + assert.deepStrictEqual({ + result, + generationChanged: firstGeneration !== secondGeneration, + centralGeneration: (await central.getSessionV2(session.toString()))?.sessionGeneration, + }, { + result: { status: 'acknowledged', sourceRevision: 0 }, + generationChanged: true, + centralGeneration: secondGeneration, + }); + }); + + test('serializes queued mutations without dropping caller payloads', async () => { + let releaseFirstWrite!: () => void; + const { local, service } = await createHarness(); + local.blockFirstWrite = new Promise(resolve => releaseFirstWrite = resolve); + + const first = service.synchronize(session, { data: data('one', 'chat-one'), legacyMetadata: { customTitle: 'one' } }); + const second = service.synchronize(session, { data: data('two', 'chat-two'), legacyMetadata: { customTitle: 'two' } }); + const third = service.synchronize(session, { data: data('three', 'chat-three'), legacyMetadata: { customTitle: 'three' } }); + releaseFirstWrite(); + + assert.deepStrictEqual({ + results: await Promise.all([first, second, third]), + writes: local.writes, + title: await local.getMetadata('customTitle'), + }, { + results: [ + { status: 'acknowledged', sourceRevision: 0 }, + { status: 'acknowledged', sourceRevision: 1 }, + { status: 'acknowledged', sourceRevision: 2 }, + ], + writes: [ + { metadata: { customTitle: 'one' }, title: 'one', chatTitle: 'chat-one' }, + { metadata: { customTitle: 'two' }, title: 'two', chatTitle: 'chat-two' }, + { metadata: { customTitle: 'three' }, title: 'three', chatTitle: 'chat-three' }, + ], + title: 'three', + }); + }); + + test('rejects synchronization until overlapping deletion fences are released', async () => { + const { local, service } = await createHarness(); + const firstFence = service.beginSessionDeletion(session); + const secondFence = service.beginSessionDeletion(session); + await Promise.all([firstFence.whenDrained, secondFence.whenDrained]); + + await assert.rejects( + service.synchronize(session, { data: data('blocked'), legacyMetadata: { customTitle: 'blocked' } }), + /Catalog synchronization rejected during session deletion/, + ); + firstFence.dispose(); + const fencedAfterFirstRelease = service.isSessionDeletionFenced(session); + await assert.rejects( + service.synchronize(session, { data: data('still-blocked'), legacyMetadata: { customTitle: 'still-blocked' } }), + /Catalog synchronization rejected during session deletion/, + ); + secondFence.dispose(); + const result = await service.synchronize(session, { data: data('recreated'), legacyMetadata: { customTitle: 'recreated' } }); + + assert.deepStrictEqual({ + fencedAfterFirstRelease, + fencedAfterSecondRelease: service.isSessionDeletionFenced(session), + result, + writes: local.writes.map(write => write.title), + }, { + fencedAfterFirstRelease: true, + fencedAfterSecondRelease: false, + result: { status: 'acknowledged', sourceRevision: 0 }, + writes: ['recreated'], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts new file mode 100644 index 00000000000000..d8036ec1322e5d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -0,0 +1,1293 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as fs from 'fs/promises'; +import { createHash } from 'crypto'; +import { tmpdir } from 'os'; +import type { Database } from '@vscode/sqlite3'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { stableStringify } from '../../../../base/common/objects.js'; +import { join } from '../../../../base/common/path.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseSessionV2Envelope } from '../../node/agentHostDatabase.js'; + +function openDatabase(path: string): Promise { + return new Promise((resolve, reject) => { + import('@vscode/sqlite3').then(sqlite3 => { + const database = new sqlite3.default.Database(path, error => error ? reject(error) : resolve(database)); + }, reject); + }); +} + +function exec(database: Database, sql: string): Promise { + return new Promise((resolve, reject) => database.exec(sql, error => error ? reject(error) : resolve())); +} + +function all(database: Database, sql: string): Promise[]> { + return new Promise((resolve, reject) => { + database.all(sql, (error: Error | null, rows: Record[]) => error ? reject(error) : resolve(rows)); + }); +} + +function close(database: Database): Promise { + return new Promise((resolve, reject) => database.close(error => error ? reject(error) : resolve())); +} + +function createPayload(session: string, sourceRevision: number, isChatBacking = false): string { + return stableStringify({ + payloadVersion: 1, + data: { + modifiedTime: 100 + sourceRevision, + summary: `Title ${sourceRevision}`, + isRead: true, + isArchived: false, + isChatBacking, + project: { uri: 'file:///project', displayName: 'Project' }, + _meta: { ehcliAdoptable: true }, + workingDirectories: ['file:///project', 'file:///project/packages/app'], + changes: { files: 2 }, + chats: [ + { kind: 'default', order: 0, summary: 'Default', titleSource: 'auto', uri: `${session}#default` }, + { kind: 'peer', order: 1, origin: { type: 'subagent' }, summary: 'Peer', titleSource: 'agent', uri: `${session}#peer` }, + ], + }, + }); +} + +function createEnvelope( + session: string, + sessionGeneration: string, + sourceRevision: number, + overrides: Partial = {}, +): IAgentHostDatabaseSessionV2Envelope { + const payload = overrides.payload ?? createPayload(session, sourceRevision); + return { + session, + sessionGeneration, + sourceRevision, + payloadVersion: 1, + payloadHash: createHash('sha256').update(payload, 'utf8').digest('hex'), + verified: true, + payload, + ...overrides, + }; +} + +/** The stored row a verified envelope produces for a session registered with `registration`. */ +function storedRow(envelope: IAgentHostDatabaseSessionV2Envelope, registration: object, isChatBacking = false) { + return { ...envelope, ...registration, isChatBacking, payloadDirty: 0 }; +} + +async function createPublishedSessionsV2Database(path: string, version: 4 | 5 | 6): Promise { + const database = await openDatabase(path); + try { + await exec(database, ` + CREATE TABLE sessions ( + session_uri TEXT PRIMARY KEY NOT NULL, + provider TEXT NOT NULL, + start_time INTEGER NOT NULL, + external INTEGER, + registration_source TEXT NOT NULL DEFAULT 'explicit' + ); + CREATE TABLE metadata (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE sessions_v2 ( + session_uri TEXT PRIMARY KEY NOT NULL REFERENCES sessions(session_uri) ON DELETE CASCADE, + provider TEXT NOT NULL, + start_time INTEGER NOT NULL, + external INTEGER, + registration_source TEXT NOT NULL, + modified_time INTEGER, + title TEXT, + title_source TEXT CHECK (title_source IN ('user', 'agent', 'auto')), + is_read INTEGER CHECK (is_read IN (0, 1)), + is_archived INTEGER CHECK (is_archived IN (0, 1)), + project_uri TEXT, + project_display_name TEXT, + workspaceless INTEGER CHECK (workspaceless IN (0, 1)), + ehcli_adoptable INTEGER CHECK (ehcli_adoptable IN (0, 1)), + working_directories_json TEXT, + chats_json TEXT, + multi_root_json TEXT, + folder_picker_json TEXT, + changes_summary_json TEXT, + github_summary_json TEXT, + git_summary_json TEXT, + source_control_summary_json TEXT, + artifacts_json TEXT, + orchestration_json TEXT, + session_generation TEXT, + source_revision INTEGER CHECK (source_revision >= 0), + projection_version INTEGER CHECK (projection_version >= 0), + source_hash TEXT, + verified INTEGER NOT NULL DEFAULT 0 CHECK (verified IN (0, 1)) + ); + INSERT INTO sessions VALUES ('session://published-${version}', 'copilot', ${version}, 1, 'discovery'); + `); + if (version >= 5) { + await exec(database, 'ALTER TABLE sessions_v2 ADD COLUMN is_chat_backing INTEGER NOT NULL DEFAULT 0 CHECK (is_chat_backing IN (0, 1))'); + } + if (version >= 6) { + await exec(database, 'ALTER TABLE sessions_v2 ADD COLUMN ehcli_adopted INTEGER CHECK (ehcli_adopted IN (0, 1))'); + } + const laterColumns = version === 4 ? '' : version === 5 ? ', is_chat_backing' : ', is_chat_backing, ehcli_adopted'; + const laterValues = version === 4 ? '' : version === 5 ? ', 1' : ', 1, 1'; + await exec(database, ` + INSERT INTO sessions_v2 ( + session_uri, provider, start_time, external, registration_source, modified_time, title, title_source, + is_read, is_archived, project_uri, project_display_name, workspaceless, ehcli_adoptable, + working_directories_json, chats_json, multi_root_json, folder_picker_json, changes_summary_json, + github_summary_json, git_summary_json, source_control_summary_json, artifacts_json, orchestration_json, + session_generation, source_revision, projection_version, source_hash, verified${laterColumns} + ) VALUES ( + 'session://published-${version}', 'copilot', ${version}, 1, 'discovery', 100, 'Published', 'user', + 1, 0, 'file:///project', 'Project', 0, 1, + '["file:///project"]', '[]', '{}', '{}', '{}', + '{}', '{}', '{}', '[]', '{}', + 'generation-${version}', 7, 4, 'published-hash', 1${laterValues} + ); + PRAGMA user_version = ${version}; + `); + } finally { + await close(database); + } +} + +async function createUpstreamVersion4Database(path: string): Promise { + const database = await openDatabase(path); + try { + await exec(database, ` + CREATE TABLE sessions ( + session_uri TEXT PRIMARY KEY NOT NULL, + provider TEXT NOT NULL, + start_time INTEGER NOT NULL, + external INTEGER NOT NULL DEFAULT 0, + registration_source TEXT NOT NULL DEFAULT 'explicit', + modified_time INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE metadata (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + INSERT INTO sessions VALUES ('copilotcli:/upstream-v4', 'copilotcli', 10, 0, 'explicit', 20); + PRAGMA user_version = 4; + `); + } finally { + await close(database); + } +} + +suite('AgentHostDatabase sessions_v2', () => { + + let database: IAgentHostDatabase | undefined; + let temporaryDirectory: string | undefined; + + setup(async () => { + temporaryDirectory = await fs.mkdtemp(join(tmpdir(), `agent-host-sessions-v2-${generateUuid()}`)); + }); + + teardown(async () => { + await database?.close(); + database = undefined; + if (temporaryDirectory) { + await fs.rm(temporaryDirectory, { recursive: true, force: true }); + temporaryDirectory = undefined; + } + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('creates the central catalog schema without changing the legacy registry', async () => { + const path = join(temporaryDirectory!, 'agent-host.db'); + database = new AgentHostDatabase(path); + await database.registerSessionV2('session://fresh', { + provider: 'copilot', + startTime: 1, + source: 'explicit', + }, { checkTombstone: false }); + assert.deepStrictEqual({ + legacy: await database.getSession('session://fresh'), + current: await database.getSessionV2Registration('session://fresh'), + complete: await database.getSessionV2('session://fresh'), + }, { + legacy: undefined, + current: { session: 'session://fresh', provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }, + complete: undefined, + }); + await database.close(); + database = undefined; + + const rawDatabase = await openDatabase(path); + try { + const [version, tables, sessionColumns, sessionV2Columns, sessionV2ForeignKeys] = await Promise.all([ + all(rawDatabase, 'PRAGMA user_version'), + all(rawDatabase, `SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`), + all(rawDatabase, 'PRAGMA table_info(sessions)'), + all(rawDatabase, 'PRAGMA table_info(sessions_v2)'), + all(rawDatabase, 'PRAGMA foreign_key_list(sessions_v2)'), + ]); + assert.deepStrictEqual({ + version, + tables: tables.map(row => row.name), + sessionColumns: sessionColumns.map(row => row.name), + sessionV2Columns: sessionV2Columns.map(row => row.name), + sessionV2ForeignKeys, + }, { + version: [{ user_version: 5 }], + tables: ['metadata', 'session_chat_catalogs', 'session_chats', 'sessions', 'sessions_v2'], + sessionColumns: ['session_uri', 'provider', 'start_time', 'external', 'registration_source', 'modified_time'], + sessionV2Columns: [ + 'session_uri', 'provider', 'start_time', 'external', 'registration_source', 'session_generation', + 'source_revision', 'payload_version', 'payload_hash', 'verified', 'payload', 'is_chat_backing', 'modified_time', + ], + sessionV2ForeignKeys: [], + }); + + } finally { + await close(rawDatabase); + } + }); + + test('stores revisioned authoritative peer-chat membership', async () => { + database = new AgentHostDatabase(join(temporaryDirectory!, 'agent-host.db')); + const session = 'session://chat-catalog'; + await database.registerRuntimeSession(session, { + provider: 'copilot', + startTime: 1, + source: 'explicit', + }, { checkTombstone: false }); + + const before = await database.getSessionChatCatalog(session); + const firstResult = await database.replaceSessionChatCatalog(session, [ + { chat: 'ahp-chat://first', order: 0, providerData: 'first', origin: '{"kind":"user"}' }, + { chat: 'ahp-chat://second', order: 1, inheritedTurnId: 'turn-1' }, + ], undefined); + if (firstResult.status !== 'applied') { + throw new Error('Expected the initial chat catalog write to succeed'); + } + const firstRevision = firstResult.revision; + const first = await database.getSessionChatCatalog(session); + const firstAcknowledged = await database.markSessionChatCatalogLegacyMirrored(session, firstRevision, '[{"uri":"ahp-chat://first"}]'); + const secondResult = await database.replaceSessionChatCatalog(session, [ + { chat: 'ahp-chat://second', order: 0, inheritedTurnId: 'turn-1' }, + ], firstRevision); + if (secondResult.status !== 'applied') { + throw new Error('Expected the second chat catalog write to succeed'); + } + const secondRevision = secondResult.revision; + const conflictingRevision = await database.replaceSessionChatCatalog(session, [], firstRevision); + const staleAcknowledgement = await database.markSessionChatCatalogLegacyMirrored(session, firstRevision, 'stale-mirror-payload'); + const afterStaleAcknowledgement = await database.getSessionChatCatalog(session); + const baseRecorded = await database.recordSessionChatCatalogLegacyMirrorPayload(session, secondRevision, 'observed-legacy-payload'); + const second = await database.getSessionChatCatalog(session); + + assert.deepStrictEqual({ + before, + firstRevision, + first, + firstAcknowledged, + secondRevision, + conflictingRevision, + staleAcknowledgement, + afterStaleAcknowledgement, + baseRecorded, + second, + }, { + before: undefined, + firstRevision: 1, + first: { + revision: 1, + legacyMirroredRevision: 0, + chats: [ + { chat: 'ahp-chat://first', order: 0, providerData: 'first', origin: '{"kind":"user"}' }, + { chat: 'ahp-chat://second', order: 1, inheritedTurnId: 'turn-1' }, + ], + }, + firstAcknowledged: true, + secondRevision: 2, + conflictingRevision: { status: 'conflict' }, + staleAcknowledgement: false, + afterStaleAcknowledgement: { + revision: 2, + legacyMirroredRevision: 1, + legacyMirroredPayload: 'stale-mirror-payload', + chats: [ + { chat: 'ahp-chat://second', order: 0, inheritedTurnId: 'turn-1' }, + ], + }, + baseRecorded: true, + second: { + revision: 2, + legacyMirroredRevision: 1, + legacyMirroredPayload: 'observed-legacy-payload', + chats: [ + { chat: 'ahp-chat://second', order: 0, inheritedTurnId: 'turn-1' }, + ], + }, + }); + }); + + test('sequences chat catalog reads behind queued replacements', async () => { + const sequencedDatabase = new AgentHostDatabase(':memory:'); + database = sequencedDatabase; + const session = 'session://sequenced-chat-catalog'; + await sequencedDatabase.registerRuntimeSession(session, { + provider: 'copilot', + startTime: 1, + source: 'explicit', + }, { checkTombstone: false }); + const release = new DeferredPromise(); + const queued = new DeferredPromise(); + const blocker = sequencedDatabase['_transactionSequencer'].queue(async () => { + await queued.complete(); + await release.p; + }); + await queued.p; + const replacement = sequencedDatabase.replaceSessionChatCatalog(session, [ + { chat: 'ahp-chat://first', order: 0 }, + { chat: 'ahp-chat://second', order: 1 }, + ], undefined); + let readSettled = false; + const read = sequencedDatabase.getSessionChatCatalog(session).finally(() => readSettled = true); + await new Promise(resolve => setTimeout(resolve, 0)); + const settledWhileWriteQueued = readSettled; + await release.complete(); + await blocker; + + assert.deepStrictEqual({ + settledWhileWriteQueued, + replacement: await replacement, + catalog: await read, + }, { + settledWhileWriteQueued: false, + replacement: { status: 'applied', revision: 1 }, + catalog: { + revision: 1, + legacyMirroredRevision: 0, + chats: [ + { chat: 'ahp-chat://first', order: 0 }, + { chat: 'ahp-chat://second', order: 1 }, + ], + }, + }); + }); + + test('rejects chat catalog replacement after session tombstoning', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://deleted-chat-catalog'; + await database.registerRuntimeSession(session, { + provider: 'copilot', + startTime: 1, + source: 'explicit', + }, { checkTombstone: false }); + const initial = await database.replaceSessionChatCatalog(session, [ + { chat: 'ahp-chat://peer', order: 0 }, + ], undefined); + await database.tombstoneAndUnregisterSession(session); + + const afterTombstone = await database.replaceSessionChatCatalog(session, [ + { chat: 'ahp-chat://late-peer', order: 0 }, + ], undefined); + const missing = await database.replaceSessionChatCatalog('session://missing-chat-catalog', [], undefined); + + assert.deepStrictEqual({ + initial, + afterTombstone, + missing, + catalog: await database.getSessionChatCatalog(session), + }, { + initial: { status: 'applied', revision: 1 }, + afterTombstone: { status: 'tombstoned' }, + missing: { status: 'missingSession' }, + catalog: undefined, + }); + }); + + test('upgrades published v4 through v6 rows and invalidates old projections', async () => { + const results: object[] = []; + for (const version of [4, 5, 6] as const) { + const path = join(temporaryDirectory!, `agent-host-published-v${version}.db`); + await createPublishedSessionsV2Database(path, version); + const upgraded = new AgentHostDatabase(path); + try { + const session = `session://published-${version}`; + const direct = `session://direct-${version}`; + await upgraded.registerSessionV2(direct, { provider: 'claude', startTime: 200 + version, source: 'explicit' }, { checkTombstone: false }); + await upgraded.unregisterSession(session); + const rawDatabase = await openDatabase(path); + const [schemaVersion, foreignKeys] = await Promise.all([ + all(rawDatabase, 'PRAGMA user_version'), + all(rawDatabase, 'PRAGMA foreign_key_list(sessions_v2)'), + ]); + await close(rawDatabase); + results.push({ + version, + schemaVersion, + foreignKeys, + published: await upgraded.getSessionV2(session), + directLegacy: await upgraded.getSession(direct), + directCurrent: await upgraded.getSessionV2Registration(direct), + }); + + } finally { + await upgraded.close(); + } + } + + assert.deepStrictEqual(results, [4, 5, 6].map(version => ({ + version, + schemaVersion: [{ user_version: 5 }], + foreignKeys: [], + published: undefined, + directLegacy: undefined, + directCurrent: { + session: `session://direct-${version}`, + provider: 'claude', + startTime: 200 + version, + modifiedTime: 200 + version, + external: false, + source: 'explicit', + }, + }))); + }); + + test('applies the catalog migration after upstream v4', async () => { + const path = join(temporaryDirectory!, 'agent-host-upstream-v4.db'); + await createUpstreamVersion4Database(path); + database = new AgentHostDatabase(path); + + assert.deepStrictEqual({ + registration: await database.getSessionV2Registration('copilotcli:/upstream-v4'), + catalog: await database.getSessionV2('copilotcli:/upstream-v4'), + }, { + registration: { + session: 'copilotcli:/upstream-v4', + provider: 'copilotcli', + startTime: 10, + modifiedTime: 20, + external: false, + source: 'explicit', + }, + catalog: undefined, + }); + await database.close(); + database = undefined; + + const rawDatabase = await openDatabase(path); + try { + assert.deepStrictEqual({ + version: await all(rawDatabase, 'PRAGMA user_version'), + tables: (await all(rawDatabase, `SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)).map(row => row.name), + }, { + version: [{ user_version: 5 }], + tables: ['metadata', 'session_chat_catalogs', 'session_chats', 'sessions', 'sessions_v2'], + }); + } finally { + await close(rawDatabase); + } + }); + + test('normalizes a pre-release v7 catalog and requires payload reseeding', async () => { + const path = join(temporaryDirectory!, 'agent-host-v7.db'); + await createPublishedSessionsV2Database(path, 6); + const v7Database = await openDatabase(path); + await exec(v7Database, 'PRAGMA user_version = 7'); + await close(v7Database); + + database = new AgentHostDatabase(path); + const registration = await database.getSessionV2Registration('session://published-6'); + const projection = await database.getSessionV2('session://published-6'); + await database.close(); + database = undefined; + + const migratedDatabase = await openDatabase(path); + const rows = await all(migratedDatabase, `SELECT + session_uri, provider, start_time, external, registration_source, session_generation, + source_revision, payload_version, payload_hash, verified, payload, is_chat_backing + FROM sessions_v2`); + await close(migratedDatabase); + + assert.deepStrictEqual({ registration, projection, rows }, { + registration: { + session: 'session://published-6', + provider: 'copilot', + startTime: 6, + modifiedTime: 6, + external: true, + source: 'discovery', + }, + projection: undefined, + rows: [{ + session_uri: 'session://published-6', + provider: 'copilot', + start_time: 6, + external: 1, + registration_source: 'discovery', + session_generation: null, + source_revision: null, + payload_version: null, + payload_hash: null, + verified: 0, + payload: null, + is_chat_backing: 0, + }], + }); + }); + + test('normalizes the pre-release v11 version without rebuilding its final catalog', async () => { + const path = join(temporaryDirectory!, 'agent-host-v11.db'); + database = new AgentHostDatabase(path); + await database.registerSessionV2('session://v11', { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.replaceSessionChatCatalog('session://v11', [ + { chat: 'ahp-chat://peer', order: 0, providerData: 'peer' }, + ], undefined); + await database.close(); + database = undefined; + + const preReleaseDatabase = await openDatabase(path); + await exec(preReleaseDatabase, 'PRAGMA user_version = 11'); + await close(preReleaseDatabase); + + database = new AgentHostDatabase(path); + const registration = await database.getSessionV2Registration('session://v11'); + const catalog = await database.getSessionChatCatalog('session://v11'); + await database.close(); + database = undefined; + + const normalizedDatabase = await openDatabase(path); + const version = await all(normalizedDatabase, 'PRAGMA user_version'); + await close(normalizedDatabase); + + assert.deepStrictEqual({ registration, catalog, version }, { + registration: { + session: 'session://v11', + provider: 'copilot', + startTime: 1, + modifiedTime: 1, + external: false, + source: 'explicit', + }, + catalog: { + revision: 1, + legacyMirroredRevision: 0, + chats: [{ chat: 'ahp-chat://peer', order: 0, providerData: 'peer' }], + }, + version: [{ user_version: 5 }], + }); + }); + + test('preserves a future migration applied to the final catalog schema', async () => { + const path = join(temporaryDirectory!, 'agent-host-future-v6.db'); + database = new AgentHostDatabase(path); + await database.registerSessionV2('session://future-v6', { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.close(); + database = undefined; + + const futureDatabase = await openDatabase(path); + await exec(futureDatabase, 'CREATE TABLE future_v6_marker (value INTEGER); PRAGMA user_version = 6'); + await close(futureDatabase); + + database = new AgentHostDatabase(path); + const registration = await database.getSessionV2Registration('session://future-v6'); + await database.close(); + database = undefined; + + const preservedDatabase = await openDatabase(path); + const version = await all(preservedDatabase, 'PRAGMA user_version'); + const marker = await all(preservedDatabase, `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'future_v6_marker'`); + await close(preservedDatabase); + + assert.deepStrictEqual({ + registration, + version, + marker, + }, { + registration: { + session: 'session://future-v6', + provider: 'copilot', + startTime: 1, + modifiedTime: 1, + external: false, + source: 'explicit', + }, + version: [{ user_version: 6 }], + marker: [{ name: 'future_v6_marker' }], + }); + }); + + test('increments dirty markers and clears only the observed marker', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://dirty-marker'; + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined); + + const first = await database.markSessionV2PayloadDirty(session); + const second = await database.markSessionV2PayloadDirty(session); + const staleClear = await database.markSessionV2PayloadClean(session, first!); + const currentClear = await database.markSessionV2PayloadClean(session, second!); + const receipt = (await database.listSessionsV2Receipts())[0]; + const { payload: _payload, ...expectedReceipt } = storedRow( + createEnvelope(session, 'generation-1', 1), + { provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }, + ); + void _payload; + await database.unregisterSessionV2(session); + await database.registerSessionV2(session, { provider: 'copilot', startTime: 2, source: 'explicit' }, { checkTombstone: false }); + const recreatedDirty = await database.markSessionV2PayloadDirty(session); + + assert.deepStrictEqual({ + first, + second, + staleClear, + currentClear, + receipt, + recreatedDirty, + }, { + first: 1, + second: 2, + staleClear: false, + currentClear: true, + receipt: { + ...expectedReceipt, + payloadDirty: 0, + }, + recreatedDirty: 1, + }); + }); + + test('upgrades published v1 through v3 schemas with incomplete v2 rows', async () => { + const results: object[] = []; + for (const version of [1, 2, 3]) { + const path = join(temporaryDirectory!, `agent-host-v${version}.db`); + const rawDatabase = await openDatabase(path); + const externalColumn = version >= 2 ? ', external INTEGER' : ''; + const sourceColumn = version >= 3 ? `, registration_source TEXT NOT NULL DEFAULT 'explicit'` : ''; + const insertColumns = version === 1 ? '' : version === 2 ? ', external' : ', external, registration_source'; + const insertValues = version === 1 ? '' : version === 2 ? ', 1' : `, 0, 'restore'`; + await exec(rawDatabase, ` + CREATE TABLE sessions ( + session_uri TEXT PRIMARY KEY NOT NULL, + provider TEXT NOT NULL, + start_time INTEGER NOT NULL${externalColumn}${sourceColumn} + ); + CREATE TABLE metadata (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + INSERT INTO sessions (session_uri, provider, start_time${insertColumns}) + VALUES ('session://upgrade-${version}', 'copilot', ${version}${insertValues}); + PRAGMA user_version = ${version}; + `); + await close(rawDatabase); + + const upgraded = new AgentHostDatabase(path); + try { + const session = await upgraded.getSession(`session://upgrade-${version}`); + const migratedDatabase = await openDatabase(path); + const migratedRows = await all(migratedDatabase, 'SELECT session_uri, provider, start_time, external, registration_source, verified FROM sessions_v2'); + await close(migratedDatabase); + results.push({ + version, + session, + sessionV2: await upgraded.getSessionV2(`session://upgrade-${version}`), + migratedRows, + }); + } finally { + await upgraded.close(); + } + } + + assert.deepStrictEqual(results, [ + { + version: 1, + session: { session: 'session://upgrade-1', provider: 'copilot', startTime: 1, modifiedTime: 1, external: undefined, source: 'explicit' }, + sessionV2: undefined, + migratedRows: [{ session_uri: 'session://upgrade-1', provider: 'copilot', start_time: 1, external: null, registration_source: 'explicit', verified: 0 }], + }, + { + version: 2, + session: { session: 'session://upgrade-2', provider: 'copilot', startTime: 2, modifiedTime: 2, external: true, source: 'discovery' }, + sessionV2: undefined, + migratedRows: [{ session_uri: 'session://upgrade-2', provider: 'copilot', start_time: 2, external: 1, registration_source: 'discovery', verified: 0 }], + }, + { + version: 3, + session: { session: 'session://upgrade-3', provider: 'copilot', startTime: 3, modifiedTime: 3, external: false, source: 'restore' }, + sessionV2: undefined, + migratedRows: [{ session_uri: 'session://upgrade-3', provider: 'copilot', start_time: 3, external: 0, registration_source: 'restore', verified: 0 }], + }, + ]); + }); + + test('round trips one complete verified row', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://round-trip'; + await database.registerSessionV2(session, { + provider: 'copilot', + startTime: 42, + source: 'restore', + }, { checkTombstone: false }); + const registration = { provider: 'copilot', startTime: 42, modifiedTime: 42, external: false, source: 'restore' }; + const envelope = createEnvelope(session, 'generation-1', 7); + + const result = await database.upsertSessionV2(envelope, undefined); + const { payload, ...receipt } = storedRow(envelope, registration); + + assert.deepStrictEqual({ + result, + row: await database.getSessionV2(session), + rows: await database.listSessionsV2(), + receipts: await database.listSessionsV2Receipts(), + }, { + result: 'applied', + row: storedRow(envelope, registration), + rows: [storedRow(envelope, registration)], + receipts: [receipt], + }); + }); + + test('derives is_chat_backing from the validated payload and rejects payloads the envelope does not describe', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://derived'; + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + const backing = createEnvelope(session, 'generation-1', 1, { payload: createPayload(session, 1, true) }); + await database.upsertSessionV2(backing, undefined); + const backingRow = await database.getSessionV2(session); + + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 2), 'generation-1'); + const clearedRow = await database.getSessionV2(session); + + await assert.rejects( + database.upsertSessionV2({ ...createEnvelope(session, 'generation-1', 3), payloadHash: 'wrong' }, 'generation-1'), + /payloadHash must match payload/, + ); + await assert.rejects( + database.upsertSessionV2(createEnvelope(session, 'generation-1', 3, { payload: '{"payloadVersion":1,"data":{}}' }), 'generation-1'), + /Catalog payload is invalid/, + ); + await assert.rejects( + database.upsertSessionV2(createEnvelope(session, 'generation-1', 3, { payload: `{"data":{},"payloadVersion":0}` }), 'generation-1'), + /Catalog payload is outdated/, + ); + + assert.deepStrictEqual({ + backing: backingRow?.isChatBacking, + cleared: clearedRow?.isChatBacking, + receipts: (await database.listSessionsV2Receipts()).map(receipt => receipt.isChatBacking), + }, { + backing: true, + cleared: false, + receipts: [false], + }); + }); + + test('guards revisions and generation transitions', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://ordering'; + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 2), undefined); + + const results = { + stale: await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), 'generation-1'), + conflict: await database.upsertSessionV2(createEnvelope(session, 'generation-1', 2, { payload: createPayload(session, 99) }), 'generation-1'), + replayed: await database.upsertSessionV2(createEnvelope(session, 'generation-1', 2), 'generation-1'), + wrongGeneration: await database.upsertSessionV2(createEnvelope(session, 'generation-2', 0), 'unknown-generation'), + transitioned: await database.upsertSessionV2(createEnvelope(session, 'generation-2', 0), 'generation-1'), + delayedOldGeneration: await database.upsertSessionV2(createEnvelope(session, 'generation-1', 3), 'generation-1'), + }; + + assert.deepStrictEqual({ + results, + row: await database.getSessionV2(session), + }, { + results: { + stale: 'stale', + conflict: 'conflict', + replayed: 'replayed', + wrongGeneration: 'generationMismatch', + transitioned: 'applied', + delayedOldGeneration: 'generationMismatch', + }, + row: storedRow(createEnvelope(session, 'generation-2', 0), { provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }), + }); + }); + + test('serializes concurrent upserts and an upsert racing deletion', async () => { + database = new AgentHostDatabase(':memory:'); + const sessions = Array.from({ length: 20 }, (_, index) => `session://concurrent-${index}`); + for (const session of sessions) { + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + } + + const upsertResults = await Promise.all(sessions.map(session => database!.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined))); + const racingSession = sessions[0]; + const [racingUpsert] = await Promise.all([ + database.upsertSessionV2(createEnvelope(racingSession, 'generation-1', 2), 'generation-1'), + database.unregisterSessionV2(racingSession), + ]); + + assert.deepStrictEqual({ + upsertResults, + racingUpsert, + deletedRow: await database.getSessionV2(racingSession), + remainingRows: (await database.listSessionsV2()).length, + }, { + upsertResults: sessions.map(() => 'applied'), + racingUpsert: 'applied', + deletedRow: undefined, + remainingRows: sessions.length - 1, + }); + }); + + test('updates current registration provenance without a catalog revision', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://provenance'; + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined); + const discovered = await database.getSessionV2(session); + + await database.registerSessionV2(session, { provider: 'ignored-provider', startTime: 2, source: 'restore' }, { checkTombstone: false }); + const restored = await database.getSessionV2(session); + await database.registerSessionV2(session, { provider: 'claude', startTime: 3, source: 'explicit' }, { checkTombstone: false }); + const explicit = await database.getSessionV2(session); + + assert.deepStrictEqual({ + discovered: discovered && { provider: discovered.provider, startTime: discovered.startTime, modifiedTime: discovered.startTime, external: discovered.external, source: discovered.source, sourceRevision: discovered.sourceRevision }, + restored: restored && { provider: restored.provider, startTime: restored.startTime, modifiedTime: restored.startTime, external: restored.external, source: restored.source, sourceRevision: restored.sourceRevision }, + explicit: explicit && { provider: explicit.provider, startTime: explicit.startTime, modifiedTime: explicit.startTime, external: explicit.external, source: explicit.source, sourceRevision: explicit.sourceRevision }, + }, { + discovered: { provider: 'copilot', startTime: 1, modifiedTime: 1, external: true, source: 'discovery', sourceRevision: 1 }, + restored: { provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'restore', sourceRevision: 1 }, + explicit: { provider: 'claude', startTime: 1, modifiedTime: 1, external: false, source: 'explicit', sourceRevision: 1 }, + }); + }); + + test('updates incomplete current provenance without changing the projection revision', async () => { + const path = join(temporaryDirectory!, 'external-backfill.db'); + const session = 'session://external-backfill'; + database = new AgentHostDatabase(path); + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined); + await database.close(); + database = undefined; + + const rawDatabase = await openDatabase(path); + await exec(rawDatabase, `UPDATE sessions_v2 SET external = NULL WHERE session_uri = '${session}'`); + await close(rawDatabase); + + database = new AgentHostDatabase(path); + await database.updateSessionV2External([{ session, external: true }]); + const row = await database.getSessionV2(session); + + assert.deepStrictEqual(row && { + external: row.external, + source: row.source, + sourceRevision: row.sourceRevision, + }, { + external: true, + source: 'discovery', + sourceRevision: 1, + }); + }); + + test('legacy reconciliation returns the exact identity after merging modified time', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://legacy-reconciliation-identity'; + await database.registerSessionV2(session, { + provider: 'copilot', + startTime: 20, + modifiedTime: 40, + source: 'restore', + }, { checkTombstone: true }); + + const reconciled = await database.reconcileSessionV2RegistrationFromLegacy(session, { + session, + provider: 'copilot', + startTime: 10, + modifiedTime: 30, + external: true, + source: 'discovery', + }); + + assert.deepStrictEqual({ + reconciled, + stored: await database.getSessionV2Registration(session), + }, { + reconciled: { session, provider: 'copilot', startTime: 10, modifiedTime: 40, external: true, source: 'discovery' }, + stored: { session, provider: 'copilot', startTime: 10, modifiedTime: 40, external: true, source: 'discovery' }, + }); + }); + + test('runtime mutations atomically mirror current identity and provenance to legacy', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://runtime-mirror'; + await database.excludeSessionV2({ + provider: 'copilot', + session, + reason: 'providerAbsent', + fingerprint: 'enumeration-v1', + }, { + identity: undefined, + catalog: undefined, + }); + + await database.registerRuntimeSession(session, { provider: 'copilot', startTime: 10, source: 'restore' }, { checkTombstone: true }); + await database.registerRuntimeSession(session, { provider: 'copilot', startTime: 20, source: 'discovery' }, { checkTombstone: true }); + + assert.deepStrictEqual({ + legacy: await database.getSession(session), + current: await database.getSessionV2Registration(session), + exclusion: await database.getSessionsV2Exclusion('copilot', session), + }, { + legacy: { session, provider: 'copilot', startTime: 10, modifiedTime: 20, external: true, source: 'discovery' }, + current: { session, provider: 'copilot', startTime: 10, modifiedTime: 20, external: true, source: 'discovery' }, + exclusion: undefined, + }); + + await database.unregisterRuntimeSession(session); + assert.deepStrictEqual({ + legacy: await database.getSession(session), + current: await database.getSessionV2Registration(session), + }, { + legacy: undefined, + current: undefined, + }); + }); + + test('runtime registration seeds legacy identity before applying discovery conflicts', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://legacy-first-runtime'; + await database.registerSession(session, { provider: 'claude', startTime: 10, source: 'explicit' }, { checkTombstone: false }); + + await database.registerRuntimeSession(session, { provider: 'copilot', startTime: 20, source: 'discovery' }, { checkTombstone: true }); + + assert.deepStrictEqual({ + legacy: await database.getSession(session), + current: await database.getSessionV2Registration(session), + keys: await database.listRuntimeCompatibleSessionKeys(), + }, { + legacy: { session, provider: 'claude', startTime: 10, modifiedTime: 20, external: false, source: 'explicit' }, + current: { session, provider: 'claude', startTime: 10, modifiedTime: 20, external: false, source: 'explicit' }, + keys: [session], + }); + }); + + test('runtime provenance resolution mirrors both registries without changing catalog revision', async () => { + const path = join(temporaryDirectory!, 'runtime-provenance.db'); + const session = 'session://runtime-provenance'; + database = new AgentHostDatabase(path); + await database.registerRuntimeSession(session, { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 3), undefined); + await database.close(); + database = undefined; + + const rawDatabase = await openDatabase(path); + await exec(rawDatabase, `UPDATE sessions_v2 SET external = NULL WHERE session_uri = '${session}'; + UPDATE sessions SET external = NULL WHERE session_uri = '${session}'`); + await close(rawDatabase); + + database = new AgentHostDatabase(path); + await database.updateRuntimeSessionExternal([{ session, external: true }]); + const current = await database.getSessionV2(session); + assert.deepStrictEqual({ + legacy: await database.getSession(session), + current: current && { + session: current.session, + provider: current.provider, + startTime: current.startTime, + modifiedTime: current.modifiedTime, + external: current.external, + source: current.source, + }, + sourceRevision: current?.sourceRevision, + }, { + legacy: { session, provider: 'copilot', startTime: 1, modifiedTime: 1, external: true, source: 'discovery' }, + current: { session, provider: 'copilot', startTime: 1, modifiedTime: 1, external: true, source: 'discovery' }, + sourceRevision: 3, + }); + }); + + test('runtime legacy mirror failure rolls back current registration', async () => { + const path = join(temporaryDirectory!, 'runtime-rollback.db'); + database = new AgentHostDatabase(path); + await database.listSessions(); + await database.close(); + database = undefined; + + const rawDatabase = await openDatabase(path); + await exec(rawDatabase, `CREATE TRIGGER fail_legacy_runtime_insert + BEFORE INSERT ON sessions + BEGIN + SELECT RAISE(ABORT, 'legacy mirror failed'); + END`); + await close(rawDatabase); + + database = new AgentHostDatabase(path); + const session = 'session://runtime-rollback'; + await assert.rejects( + database.registerRuntimeSession(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }), + /legacy mirror failed/, + ); + assert.deepStrictEqual({ + legacy: await database.getSession(session), + current: await database.getSessionV2Registration(session), + }, { + legacy: undefined, + current: undefined, + }); + }); + + test('legacy and current rows diverge independently', async () => { + const path = join(temporaryDirectory!, 'old-build.db'); + database = new AgentHostDatabase(path); + const currentOnly = 'session://current-only'; + await database.registerSessionV2(currentOnly, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createEnvelope(currentOnly, 'generation-1', 1), undefined); + await database.registerSession(currentOnly, { provider: 'claude', startTime: 99, source: 'discovery' }, { checkTombstone: true }); + await database.unregisterSession(currentOnly); + await database.close(); + database = undefined; + + const oldBuildDatabase = await openDatabase(path); + await exec(oldBuildDatabase, `INSERT INTO sessions (session_uri, provider, start_time, external, registration_source) + VALUES ('session://old-build', 'copilot', 2, 1, 'discovery')`); + await close(oldBuildDatabase); + + database = new AgentHostDatabase(path); + assert.deepStrictEqual({ + currentOnlyLegacy: await database.getSession(currentOnly), + currentOnlyV2: await database.getSessionV2(currentOnly), + oldBuildSession: await database.getSession('session://old-build'), + oldBuildSessionV2: await database.getSessionV2Registration('session://old-build'), + }, { + currentOnlyLegacy: undefined, + currentOnlyV2: storedRow(createEnvelope(currentOnly, 'generation-1', 1), { provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }), + oldBuildSession: { session: 'session://old-build', provider: 'copilot', startTime: 2, modifiedTime: 0, external: true, source: 'discovery' }, + oldBuildSessionV2: undefined, + }); + }); + + test('legacy row absence is not current deletion', async () => { + const path = join(temporaryDirectory!, 'old-build-orphan.db'); + const session = 'session://old-build-orphan'; + database = new AgentHostDatabase(path); + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined); + await database.close(); + database = undefined; + + const oldBuildDatabase = await openDatabase(path); + await exec(oldBuildDatabase, `PRAGMA foreign_keys = OFF; DELETE FROM sessions WHERE session_uri = '${session}'`); + const orphanRows = await all(oldBuildDatabase, `SELECT session_uri FROM sessions_v2 WHERE session_uri = '${session}'`); + await close(oldBuildDatabase); + + database = new AgentHostDatabase(path); + assert.deepStrictEqual({ + orphanRows, + get: await database.getSessionV2(session), + list: await database.listSessionsV2(), + }, { + orphanRows: [{ session_uri: session }], + get: storedRow(createEnvelope(session, 'generation-1', 1), { provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }), + list: [storedRow(createEnvelope(session, 'generation-1', 1), { provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' })], + }); + }); + + test('tombstone prevents current import and explicit recreation clears it', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://tombstoned-read'; + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined); + await database.tombstoneAndUnregisterSession(session); + const imported = await database.registerSessionV2(session, { provider: 'copilot', startTime: 2, source: 'discovery' }, { checkTombstone: true }); + const explicit = await database.registerSessionV2(session, { provider: 'claude', startTime: 3, source: 'explicit' }, { checkTombstone: false }); + + assert.deepStrictEqual({ + imported, + explicit, + tombstoned: await database.isSessionTombstoned(session), + registration: await database.getSessionV2Registration(session), + complete: await database.getSessionV2(session), + }, { + imported: false, + explicit: true, + tombstoned: false, + registration: { session, provider: 'claude', startTime: 3, modifiedTime: 3, external: false, source: 'explicit' }, + complete: undefined, + }); + }); + + test('payload-versioned markers do not alter old marker semantics', async () => { + database = new AgentHostDatabase(':memory:'); + await database.markSessionRegistryBackfilled(); + await database.markProviderBackfilled('copilot'); + await database.markSessionsV2Backfilled('copilot', 5); + + assert.deepStrictEqual({ + global: await database.isSessionRegistryBackfilled(), + provider: await database.isProviderBackfilled('copilot'), + currentV4: await database.isSessionsV2Backfilled('copilot', 4), + currentV5: await database.isSessionsV2Backfilled('copilot', 5), + claudeV5: await database.isSessionsV2Backfilled('claude', 5), + }, { + global: true, + provider: true, + currentV4: false, + currentV5: true, + claudeV5: false, + }); + }); + + test('repeated current registration keeps one incomplete row', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://incomplete'; + await Promise.all(Array.from({ length: 20 }, () => database!.registerSessionV2( + session, + { provider: 'copilot', startTime: 1, source: 'discovery' }, + { checkTombstone: true }, + ))); + + assert.deepStrictEqual({ + registrations: await database.listSessionV2Registrations(), + complete: await database.listSessionsV2(), + }, { + registrations: [{ session, provider: 'copilot', startTime: 1, modifiedTime: 1, external: true, source: 'discovery' }], + complete: [], + }); + }); + + test('current-v2 exclusions are durable, hide rows, and clear on eligible registration', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'copilot:/excluded'; + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined); + await database.excludeSessionV2({ + provider: 'copilot', + session, + reason: 'staleExternal', + fingerprint: '123', + }, { + identity: await database.getSessionV2Registration(session), + catalog: { + sessionGeneration: 'generation-1', + sourceRevision: 1, + payloadHash: createEnvelope(session, 'generation-1', 1).payloadHash, + }, + }); + + const excluded = { + single: await database.getSessionsV2Exclusion('copilot', session), + list: await database.listSessionsV2Exclusions('copilot'), + registration: await database.getSessionV2Registration(session), + projection: await database.getSessionV2(session), + }; + await database.registerSessionV2(session, { provider: 'copilot', startTime: 2, source: 'discovery' }, { checkTombstone: true }); + + assert.deepStrictEqual({ + excluded, + revivedExclusion: await database.getSessionsV2Exclusion('copilot', session), + revivedRegistration: await database.getSessionV2Registration(session), + }, { + excluded: { + single: { provider: 'copilot', session, reason: 'staleExternal', fingerprint: '123' }, + list: [{ provider: 'copilot', session, reason: 'staleExternal', fingerprint: '123' }], + registration: undefined, + projection: undefined, + }, + revivedExclusion: undefined, + revivedRegistration: { session, provider: 'copilot', startTime: 2, modifiedTime: 2, external: true, source: 'discovery' }, + }); + }); + + test('batches provider exclusions and lists only the indexed provider range', async () => { + database = new AgentHostDatabase(':memory:'); + await database.markSessionsV2ExcludedBatch?.([ + { provider: 'copilot', session: 'copilot:/a', reason: 'staleExternal', fingerprint: '1' }, + { provider: 'copilot', session: 'copilot:/b', reason: 'backing', fingerprint: 'backing-v1' }, + { provider: 'claude', session: 'claude:/c', reason: 'subagent', fingerprint: 'uri-v1' }, + ]); + + assert.deepStrictEqual(await database.listSessionsV2Exclusions('copilot'), [ + { provider: 'copilot', session: 'copilot:/a', reason: 'staleExternal', fingerprint: '1' }, + { provider: 'copilot', session: 'copilot:/b', reason: 'backing', fingerprint: 'backing-v1' }, + ]); + }); + + test('atomically excludes identities and ignores stale discovery exclusions after registration', async () => { + database = new AgentHostDatabase(':memory:'); + const excluded = 'copilot:/atomic-exclusion'; + const registered = 'copilot:/registered-before-batch'; + await database.registerSessionV2(excluded, { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + await database.upsertSessionV2(createEnvelope(excluded, 'generation-1', 1), undefined); + + await database.excludeSessionV2({ + provider: 'copilot', + session: excluded, + reason: 'staleExternal', + fingerprint: '1', + }, { + identity: await database.getSessionV2Registration(excluded), + catalog: { + sessionGeneration: 'generation-1', + sourceRevision: 1, + payloadHash: createEnvelope(excluded, 'generation-1', 1).payloadHash, + }, + }); + const excludedUpsert = await database.upsertSessionV2(createEnvelope(excluded, 'generation-1', 2), 'generation-1'); + + await database.registerSessionV2(registered, { provider: 'copilot', startTime: 2, source: 'discovery' }, { checkTombstone: true }); + await database.markSessionsV2ExcludedBatch?.([{ + provider: 'copilot', + session: registered, + reason: 'staleExternal', + fingerprint: '2', + }]); + + assert.deepStrictEqual({ + excludedRegistration: await database.getSessionV2Registration(excluded), + excludedMarker: await database.getSessionsV2Exclusion('copilot', excluded), + excludedUpsert, + registeredIdentity: await database.getSessionV2Registration(registered), + staleMarker: await database.getSessionsV2Exclusion('copilot', registered), + }, { + excludedRegistration: undefined, + excludedMarker: { provider: 'copilot', session: excluded, reason: 'staleExternal', fingerprint: '1' }, + excludedUpsert: 'missingSession', + registeredIdentity: { session: registered, provider: 'copilot', startTime: 2, modifiedTime: 2, external: true, source: 'discovery' }, + staleMarker: undefined, + }); + }); + + test('discovery registration racing exclusion preserves the newly registered identity and payload', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'copilot:/exclusion-registration-race'; + const observed = { + identity: await database.getSessionV2Registration(session), + catalog: undefined, + }; + const envelope = createEnvelope(session, 'discovery-generation', 1); + + await database.registerSessionV2(session, { + provider: 'copilot', + startTime: 10, + modifiedTime: 20, + source: 'discovery', + }, { checkTombstone: true }); + await database.upsertSessionV2(envelope, undefined); + const exclusion = await database.excludeSessionV2({ + provider: 'copilot', + session, + reason: 'staleExternal', + fingerprint: '1', + }, observed); + + assert.deepStrictEqual({ + exclusion, + identity: await database.getSessionV2Registration(session), + payload: (await database.getSessionV2(session))?.payloadHash, + marker: await database.getSessionsV2Exclusion('copilot', session), + }, { + exclusion: 'stale', + identity: { session, provider: 'copilot', startTime: 10, modifiedTime: 20, external: true, source: 'discovery' }, + payload: envelope.payloadHash, + marker: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts new file mode 100644 index 00000000000000..6dc41855e18c1c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -0,0 +1,653 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { ChatOriginKind } from '../../common/state/protocol/state.js'; +import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js'; +import { AGENT_HOST_CATALOG_CHILD_LIMIT } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; +import { AgentHostPeerChatStore, CHAT_ORIGIN_METADATA_KEY, CHAT_PROVIDER_DATA_METADATA_KEY, PEER_CHATS_METADATA_KEY } from '../../node/agentHostPeerChatStore.js'; +import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; + +const session = URI.parse('agenthost:peer-store'); +const first = URI.parse(buildChatUri(session, 'first')); +const second = URI.parse(buildChatUri(session, 'second')); +const third = URI.parse(buildChatUri(session, 'third')); +const origin = { + kind: ChatOriginKind.SideChat, + chat: buildDefaultChatUri(session), + turnId: 'turn-1', + selection: { text: 'selected', responsePartId: 'response-1' }, +} as const; + +class FailingLegacyMirrorDatabase extends TestSessionDatabase { + private legacyMirrorFailures = 0; + + failLegacyMirrors(count: number): void { + this.legacyMirrorFailures = count; + } + + override async setMetadata(key: string, value: string): Promise { + if (key === PEER_CHATS_METADATA_KEY && this.legacyMirrorFailures > 0) { + this.legacyMirrorFailures--; + throw new Error('legacy mirror failed'); + } + return super.setMetadata(key, value); + } +} + +class RecordingLogService extends NullLogService { + readonly errors: (string | Error)[] = []; + + override error(message: string | Error): void { + this.errors.push(message); + } +} + +class ConcurrentMetadataWriteDatabase extends TestSessionDatabase { + private inFlightWrites = 0; + maxInFlightWrites = 0; + metadataValueWrites = 0; + + override async setMetadataValues(values: Readonly>): Promise { + this.metadataValueWrites++; + this.inFlightWrites++; + this.maxInFlightWrites = Math.max(this.maxInFlightWrites, this.inFlightWrites); + await Promise.resolve(); + try { + await super.setMetadataValues(values); + } finally { + this.inFlightWrites--; + } + } +} + +suite('AgentHostPeerChatStore', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let orchestrator: AgentHostDatabase; + + setup(async () => { + orchestrator = new AgentHostDatabase(':memory:'); + await orchestrator.registerRuntimeSession(session.toString(), { + provider: 'copilot', + startTime: 1, + source: 'explicit', + }, { checkTombstone: false }); + }); + + teardown(async () => { + await orchestrator.close(); + }); + + function createStore(database: TestSessionDatabase, logService = new NullLogService()): AgentHostPeerChatStore { + return new AgentHostPeerChatStore(orchestrator, createSessionDataService(database), logService); + } + + test('migration-only membership does not create compatibility databases and mirrors after adoption', async () => { + const database = new TestSessionDatabase(); + let opens = 0; + const unavailable = { + ...createSessionDataService(database), + openDatabase: () => { + opens++; + throw new Error('must not create a database'); + }, + tryOpenDatabase: async () => undefined, + }; + const migrationStore = new AgentHostPeerChatStore(orchestrator, unavailable, new NullLogService()); + + await migrationStore.replaceForMigration(session, [{ uri: first.toString(), providerData: 'provider-data', origin, inheritedTurnId: 'inherited' }]); + const catalog = await orchestrator.getSessionChatCatalog(session.toString()); + const read = await migrationStore.tryRead(session); + await migrationStore.reconcileLegacy(session); + + const adoptedStore = createStore(database); + await adoptedStore.reconcileLegacy(session); + + assert.deepStrictEqual({ + opens, + read, + compatibilityAcknowledged: catalog?.legacyMirroredRevision === catalog?.revision, + recordedBase: catalog?.legacyMirroredPayload, + legacy: await adoptedStore.tryReadLegacy(session), + }, { + opens: 0, + read: [{ uri: first.toString(), providerData: 'provider-data', origin, inheritedTurnId: 'inherited' }], + compatibilityAcknowledged: false, + recordedBase: JSON.stringify([{ uri: first.toString(), providerData: 'provider-data', origin, inheritedTurnId: 'inherited' }]), + legacy: [{ uri: first.toString(), providerData: 'provider-data', origin, inheritedTurnId: 'inherited' }], + }); + }); + + test('merges an older-build delta against migration-only membership before mirroring', async () => { + const unavailable = { + ...createSessionDataService(), + openDatabase: () => { + throw new Error('must not create a database'); + }, + tryOpenDatabase: async () => undefined, + }; + const migrationStore = new AgentHostPeerChatStore(orchestrator, unavailable, new NullLogService()); + await migrationStore.replaceForMigration(session, [{ uri: first.toString() }]); + const imported = await orchestrator.getSessionChatCatalog(session.toString()); + assert.ok(imported); + const updated = await orchestrator.replaceSessionChatCatalog(session.toString(), [ + { chat: first.toString(), order: 0 }, + { chat: second.toString(), order: 1 }, + ], imported.revision); + assert.strictEqual(updated.status, 'applied'); + + const database = new TestSessionDatabase(); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify([{ uri: third.toString() }])); + const store = createStore(database); + + const reconciled = await store.reconcileLegacy(session); + const catalog = await orchestrator.getSessionChatCatalog(session.toString()); + + assert.deepStrictEqual({ + reconciled, + legacy: await store.tryReadLegacy(session), + catalog: catalog && { + entries: catalog.chats.map(chat => chat.chat), + compatibilityAcknowledged: catalog.legacyMirroredRevision === catalog.revision, + }, + }, { + reconciled: [{ uri: third.toString() }, { uri: second.toString() }], + legacy: [{ uri: third.toString() }, { uri: second.toString() }], + catalog: { + entries: [third.toString(), second.toString()], + compatibilityAcknowledged: true, + }, + }); + }); + + test('merges an older-build addition made after migration-only authoritative empty', async () => { + const unavailable = { + ...createSessionDataService(), + openDatabase: () => { + throw new Error('must not create a database'); + }, + tryOpenDatabase: async () => undefined, + }; + const migrationStore = new AgentHostPeerChatStore(orchestrator, unavailable, new NullLogService()); + await migrationStore.replaceForMigration(session, []); + const database = new TestSessionDatabase(); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify([{ uri: first.toString() }])); + const store = createStore(database); + + const reconciled = await store.reconcileLegacy(session); + + assert.deepStrictEqual({ + reconciled, + central: await store.tryRead(session), + legacy: await store.tryReadLegacy(session), + }, { + reconciled: [{ uri: first.toString() }], + central: [{ uri: first.toString() }], + legacy: [{ uri: first.toString() }], + }); + }); + + test('does not resurrect a stale pre-deletion mirror during unmirrored repair', async () => { + const database = new FailingLegacyMirrorDatabase(); + const store = createStore(database); + await store.replace(session, [{ uri: first.toString() }]); + database.failLegacyMirrors(1); + await store.remove(session, first); + + const reconciled = await store.reconcileLegacy(session); + + assert.deepStrictEqual({ + reconciled, + central: await store.tryRead(session), + legacy: await store.tryReadLegacy(session), + }, { + reconciled: [], + central: [], + legacy: [], + }); + }); + + test('migration import does not replace a catalog created after its initial read', async () => { + class RacingDatabase extends AgentHostDatabase { + private raced = false; + + override async getSessionChatCatalog(sessionKey: string) { + if (!this.raced) { + this.raced = true; + await super.replaceSessionChatCatalog(sessionKey, [{ chat: second.toString(), order: 0, providerData: 'concurrent' }], undefined); + return undefined; + } + return super.getSessionChatCatalog(sessionKey); + } + } + await orchestrator.close(); + orchestrator = new RacingDatabase(':memory:'); + await orchestrator.registerRuntimeSession(session.toString(), { + provider: 'copilot', + startTime: 1, + source: 'explicit', + }, { checkTombstone: false }); + const store = createStore(new TestSessionDatabase()); + + await store.replaceForMigration(session, [{ uri: first.toString(), providerData: 'migration' }]); + + assert.deepStrictEqual(await store.tryRead(session, false), [{ uri: second.toString(), providerData: 'concurrent' }]); + }); + + test('heals malformed metadata on the next write', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + await database.setMetadata(PEER_CHATS_METADATA_KEY, '{"not":"an array"}'); + + const before = await store.tryReadLegacy(session); + await store.upsert(session, first, 'provider-data', { kind: ChatOriginKind.User }); + + assert.deepStrictEqual({ + before, + entries: await store.tryRead(session), + raw: await database.getMetadata(PEER_CHATS_METADATA_KEY), + }, { + before: undefined, + entries: [{ uri: first.toString(), providerData: 'provider-data', origin: { kind: ChatOriginKind.User } }], + raw: JSON.stringify([{ uri: first.toString(), providerData: 'provider-data', origin: { kind: ChatOriginKind.User } }]), + }); + }); + + test('filters duplicate, foreign, default, and invalid entries while normalizing origins', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + const foreignSession = URI.parse('agenthost:foreign'); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify([ + { uri: first.toString(), providerData: 'first', origin }, + { uri: first.toString(), providerData: 'duplicate' }, + { uri: buildChatUri(foreignSession, 'foreign') }, + { uri: buildDefaultChatUri(session) }, + { uri: second.toString(), providerData: 42 }, + { + uri: third.toString(), + origin: { + kind: ChatOriginKind.SideChat, + chat: buildDefaultChatUri(session), + turnId: 'turn-2', + selection: { text: 'kept', responsePartId: false }, + }, + }, + ])); + + assert.deepStrictEqual(await store.tryReadLegacy(session), [ + { uri: first.toString(), providerData: 'first', origin }, + { + uri: third.toString(), + origin: { + kind: ChatOriginKind.SideChat, + chat: buildDefaultChatUri(session), + turnId: 'turn-2', + }, + }, + ]); + }); + + test('serializes concurrent add, remove, and update operations', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + await store.replace(session, [ + { uri: first.toString(), providerData: 'old', origin }, + { uri: second.toString(), providerData: 'remove' }, + ]); + + await Promise.all([ + store.upsert(session, third, 'third', { kind: ChatOriginKind.User }), + store.remove(session, second), + store.upsert(session, first, 'refreshed'), + ]); + + assert.deepStrictEqual(await store.tryRead(session), [ + { uri: third.toString(), providerData: 'third', origin: { kind: ChatOriginKind.User } }, + { uri: first.toString(), providerData: 'refreshed', origin }, + ]); + }); + + test('does not recreate membership or compatibility data after tombstoning', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + await orchestrator.tombstoneAndUnregisterSession(session.toString()); + + await store.upsert(session, first, 'late-provider-data'); + + assert.deepStrictEqual({ + central: await store.tryRead(session), + legacy: await database.getMetadata(PEER_CHATS_METADATA_KEY), + chatProviderData: await database.getMetadata(CHAT_PROVIDER_DATA_METADATA_KEY), + }, { + central: undefined, + legacy: undefined, + chatProviderData: undefined, + }); + }); + + test('keeps overlapping deletion fences active until every disposer exits', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + await store.beginSessionDeletion(session); + await store.beginSessionDeletion(session); + store.endSessionDeletion(session); + + await store.upsert(session, first, 'provider-data'); + + assert.strictEqual(await store.tryRead(session), undefined); + store.endSessionDeletion(session); + }); + + test('does not create membership for a missing registered session', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + await orchestrator.unregisterRuntimeSession(session.toString()); + + await store.upsert(session, first, 'provider-data'); + + assert.deepStrictEqual({ + central: await store.tryRead(session), + legacy: await store.tryReadLegacy(session), + }, { + central: undefined, + legacy: undefined, + }); + }); + + test('restores authoritative side-chat selection from chat-local metadata', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + const selectionText = 'selected text '.repeat(400); + await database.setMetadata(CHAT_ORIGIN_METADATA_KEY, JSON.stringify({ + kind: ChatOriginKind.SideChat, + chat: buildDefaultChatUri(session), + turnId: 'turn-1', + selection: { text: selectionText, responsePartId: 'response-1' }, + })); + + const restored = await store.readLocalChatMetadata([{ + uri: first.toString(), + origin: { kind: ChatOriginKind.SideChat, chat: buildDefaultChatUri(session), turnId: 'turn-1' }, + }]); + + assert.strictEqual(restored[0].origin?.kind === ChatOriginKind.SideChat && restored[0].origin.selection?.text, selectionText); + }); + + test('retries concurrent mutations from separate store instances', async () => { + const database = new TestSessionDatabase(); + const firstStore = createStore(database); + const secondStore = createStore(database); + await firstStore.replace(session, []); + + await Promise.all([ + firstStore.upsert(session, first, 'first'), + secondStore.upsert(session, second, 'second'), + ]); + + assert.deepStrictEqual( + (await firstStore.tryRead(session))?.slice().sort((a, b) => a.uri.localeCompare(b.uri)), + [ + { uri: first.toString(), providerData: 'first' }, + { uri: second.toString(), providerData: 'second' }, + ].sort((a, b) => a.uri.localeCompare(b.uri)), + ); + }); + + test('refreshes provider data without dropping persisted origin or inherited turn', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + await store.upsert(session, first, 'old', origin, 'inherited-turn'); + + await store.upsert(session, first, 'refreshed'); + + assert.deepStrictEqual(await store.tryRead(session), [ + { uri: first.toString(), providerData: 'refreshed', origin, inheritedTurnId: 'inherited-turn' }, + ]); + }); + + test('persists and reads the explicit empty sentinel', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + + await store.replace(session, []); + + assert.deepStrictEqual({ + entries: await store.tryRead(session), + raw: await database.getMetadata(PEER_CHATS_METADATA_KEY), + }, { + entries: [], + raw: '[]', + }); + }); + + test('bounds concurrent compatibility chat-metadata writes', async () => { + const database = new ConcurrentMetadataWriteDatabase(); + const store = createStore(database); + const entries = Array.from({ length: 12 }, (_, index) => ({ + uri: buildChatUri(session, `concurrent-${index}`), + })); + + await store.replace(session, entries); + + assert.deepStrictEqual({ + writes: database.metadataValueWrites, + maxInFlight: database.maxInFlightWrites, + }, { + writes: entries.length, + maxInFlight: 4, + }); + }); + + test('rejects oversized imported legacy membership without changing central authority', async () => { + const database = new ConcurrentMetadataWriteDatabase(); + const store = createStore(database); + await store.replace(session, [{ uri: first.toString(), providerData: 'central' }]); + database.metadataValueWrites = 0; + const entries = Array.from({ length: AGENT_HOST_CATALOG_CHILD_LIMIT + 2 }, (_, index) => ({ + uri: buildChatUri(session, `legacy-${index}`), + providerData: `${index}`, + })); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(entries)); + + const reconciled = await store.reconcileLegacy(session); + const central = await store.tryRead(session, false); + + assert.deepStrictEqual({ + reconciled, + central, + chatMetadataWrites: database.metadataValueWrites, + }, { + reconciled: [{ uri: first.toString(), providerData: 'central' }], + central: [{ uri: first.toString(), providerData: 'central' }], + chatMetadataWrites: 1, + }); + }); + + test('imports at most one fewer peer than the catalog child limit', async () => { + const database = new ConcurrentMetadataWriteDatabase(); + const store = createStore(database); + const entries = Array.from({ length: AGENT_HOST_CATALOG_CHILD_LIMIT - 1 }, (_, index) => ({ + uri: buildChatUri(session, `legacy-${index}`), + })); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(entries)); + + const reconciled = await store.reconcileLegacy(session); + + assert.deepStrictEqual({ + reconciledLength: reconciled?.length, + compatibilityWrites: database.metadataValueWrites, + maxInFlightWrites: database.maxInFlightWrites, + }, { + reconciledLength: AGENT_HOST_CATALOG_CHILD_LIMIT - 1, + compatibilityWrites: AGENT_HOST_CATALOG_CHILD_LIMIT - 1, + maxInFlightWrites: 4, + }); + }); + + test('does not truncate authoritative current membership writes', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + const entries = Array.from({ length: AGENT_HOST_CATALOG_CHILD_LIMIT + 1 }, (_, index) => ({ + uri: buildChatUri(session, `current-${index}`), + })); + + await store.replace(session, entries); + await store.reconcileLegacy(session); + const additional = { uri: buildChatUri(session, 'current-additional') }; + await store.upsert(session, URI.parse(additional.uri), undefined); + const central = await store.tryRead(session, false); + + assert.deepStrictEqual({ + length: central?.length, + last: central?.at(-1), + }, { + length: entries.length + 1, + last: additional, + }); + }); + + test('republishes central membership when the acknowledged legacy mirror is missing or malformed', async () => { + const initialDatabase = new TestSessionDatabase(); + const initialStore = createStore(initialDatabase); + await initialStore.replace(session, [{ uri: first.toString(), providerData: 'central' }]); + + const missingDatabase = new TestSessionDatabase(); + const missingStore = createStore(missingDatabase); + const missingResult = await missingStore.reconcileLegacy(session); + const missingMirror = await missingDatabase.getMetadata(PEER_CHATS_METADATA_KEY); + + await missingDatabase.setMetadata(PEER_CHATS_METADATA_KEY, '{"not":"an array"}'); + const malformedResult = await missingStore.reconcileLegacy(session); + + assert.deepStrictEqual({ + missingResult, + missingMirror, + malformedResult, + repairedMirror: await missingDatabase.getMetadata(PEER_CHATS_METADATA_KEY), + }, { + missingResult: [{ uri: first.toString(), providerData: 'central' }], + missingMirror: JSON.stringify([{ uri: first.toString(), providerData: 'central' }]), + malformedResult: [{ uri: first.toString(), providerData: 'central' }], + repairedMirror: JSON.stringify([{ uri: first.toString(), providerData: 'central' }]), + }); + }); + + test('returns central membership when republishing a missing legacy mirror fails', async () => { + const initialDatabase = new TestSessionDatabase(); + const initialStore = createStore(initialDatabase); + await initialStore.replace(session, [{ uri: first.toString(), providerData: 'central' }]); + + const database = new FailingLegacyMirrorDatabase(); + database.failLegacyMirrors(1); + const logService = new RecordingLogService(); + const store = createStore(database, logService); + + assert.deepStrictEqual({ + reconciled: await store.reconcileLegacy(session), + legacy: await store.tryReadLegacy(session), + errors: logService.errors.map(error => error instanceof Error ? error.message : error), + }, { + reconciled: [{ uri: first.toString(), providerData: 'central' }], + legacy: undefined, + errors: ['legacy mirror failed'], + }); + }); + + test('imports membership changed by an older build into central authority', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify([ + { uri: first.toString(), providerData: 'first' }, + ])); + + const firstImport = await store.reconcileLegacy(session); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify([ + { uri: second.toString(), providerData: 'second' }, + ])); + const secondImport = await store.reconcileLegacy(session); + + assert.deepStrictEqual({ + firstImport, + secondImport, + central: await store.tryRead(session), + }, { + firstImport: [{ uri: first.toString(), providerData: 'first' }], + secondImport: [{ uri: second.toString(), providerData: 'second' }], + central: [{ uri: second.toString(), providerData: 'second' }], + }); + }); + + test('merges older-build changes made after an interrupted compatibility mirror', async () => { + const database = new FailingLegacyMirrorDatabase(); + const store = createStore(database); + await store.replace(session, [{ uri: first.toString() }]); + + database.failLegacyMirrors(1); + await store.upsert(session, second, undefined); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify([ + { uri: third.toString() }, + ])); + + const beforeRepair = await store.tryRead(session); + const reconciled = await store.reconcileLegacy(session); + + assert.deepStrictEqual({ + beforeRepair, + reconciled, + central: await store.tryRead(session), + legacy: await store.tryReadLegacy(session), + }, { + beforeRepair: [ + { uri: first.toString() }, + { uri: second.toString() }, + ], + reconciled: [ + { uri: third.toString() }, + { uri: second.toString() }, + ], + central: [ + { uri: third.toString() }, + { uri: second.toString() }, + ], + legacy: [ + { uri: third.toString() }, + { uri: second.toString() }, + ], + }); + }); + + test('preserves older-build changes when a new write follows an interrupted mirror', async () => { + const database = new FailingLegacyMirrorDatabase(); + const store = createStore(database); + await store.replace(session, [{ uri: first.toString() }]); + database.failLegacyMirrors(1); + await store.upsert(session, second, undefined); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify([ + { uri: third.toString() }, + ])); + database.failLegacyMirrors(1); + + await store.remove(session, first); + + assert.deepStrictEqual({ + central: await store.tryRead(session), + legacy: await store.tryReadLegacy(session), + }, { + central: [ + { uri: third.toString() }, + { uri: second.toString() }, + ], + legacy: [ + { uri: third.toString() }, + { uri: second.toString() }, + ], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index 8d2919ff267493..bf96ba4093c8bd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -17,7 +17,7 @@ import { ActionType, NotificationType } from '../../common/state/sessionActions. import { buildChatUri, buildDefaultChatUri, MessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ResponsePart, type SessionSummary, type ToolCallCompletedState, type Turn } from '../../common/state/sessionState.js'; import { type AutoMergeMethod, type CreatedPullRequest, type GitHubIssueOrPullRequest, type IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; -import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; import { sessionServerToolDefinitions } from '../../node/shared/sessionServerTools.js'; import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; @@ -141,6 +141,7 @@ suite('AgentHostSessionTitleController', () => { session: URI; db: TestSessionDatabase; titleActions: string[]; + catalogSyncs: { session: string; metadataOverrides: Readonly> }[]; copilotApiService: TestCopilotApiService; octoKitService: TestAgentHostOctoKitService; } { @@ -149,6 +150,7 @@ suite('AgentHostSessionTitleController', () => { const session = URI.parse('agenthost-session://copilot/session-title-test'); stateManager.createSession(createSummary(session, title, isEphemeral)); const titleActions: string[] = []; + const catalogSyncs: { session: string; metadataOverrides: Readonly> }[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => { if (e.action.type === ActionType.SessionTitleChanged) { titleActions.push(e.action.title); @@ -156,6 +158,7 @@ suite('AgentHostSessionTitleController', () => { })); const controller = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: createSessionDataService(db), + queueCatalogSync: (session, metadataOverrides) => catalogSyncs.push({ session, metadataOverrides }), getGitHubCopilotToken, getGitHubToken, getGitHubHost, @@ -164,9 +167,32 @@ suite('AgentHostSessionTitleController', () => { copilotApiService, isActiveAgentTitleGenerationEnabled: () => activeAgentTitleGeneration, }, new NullLogService())); - return { controller, stateManager, session, db, titleActions, copilotApiService, octoKitService }; + return { controller, stateManager, session, db, titleActions, catalogSyncs, copilotApiService, octoKitService }; } + test('queues matching parent catalog overrides for automatic and manual peer titles', () => { + const { controller, stateManager, session, catalogSyncs } = setup(); + const chat = buildChatUri(session.toString(), 'peer-catalog-title'); + stateManager.addChat(session.toString(), chat, {}); + + controller.markTitleAuto(session.toString(), chat, 'Automatic title'); + controller.markTitleRenamed(session.toString(), chat, 'Manual title'); + + assert.deepStrictEqual(catalogSyncs, [{ + session: session.toString(), + metadataOverrides: { + [customChatTitleMetadataKey(chat)]: 'Automatic title', + [customChatTitleSourceMetadataKey(chat)]: AGENT_HOST_TITLE_SOURCE_AUTO, + }, + }, { + session: session.toString(), + metadataOverrides: { + [customChatTitleMetadataKey(chat)]: 'Manual title', + [customChatTitleSourceMetadataKey(chat)]: AGENT_HOST_TITLE_SOURCE_USER, + }, + }]); + }); + test('active-agent mode completes the word crossing the 40-character fallback target without utility generation', async () => { const copilotApiService = new TestCopilotApiService(); const { controller, session, db, titleActions } = setup(copilotApiService, '', undefined, undefined, undefined, undefined, undefined, true); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 9a87891adb2b81..1875977d3cf074 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -12,7 +12,7 @@ import type { Database } from '@vscode/sqlite3'; import { mkdtempSync, readFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { fileURLToPath } from 'url'; -import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { DeferredPromise, disposableTimeout, timeout } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -34,23 +34,26 @@ import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostExternalSessions import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js'; import { CodexSessionConfigKey } from '../../common/codexSessionConfigKeys.js'; -import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import { ISessionCatalogSyncPendingSnapshot, ISessionDatabase, ISessionDataService, SessionCatalogSyncWriteResult } from '../../common/sessionDataService.js'; import { META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; import { GitRefType } from '../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type INotification } from '../../common/state/sessionActions.js'; -import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; -import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isMessageRequestHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionGitState, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn, createErrorResponsePart } from '../../common/state/sessionState.js'; +import { ChatInteractivity, type Message, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; +import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY } from '../../common/meta/agentDevContainerWorktreeMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; -import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions } from '../../node/agentHostDatabase.js'; -import { AgentSessionRegistry } from '../../node/agentSessionRegistry.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionChatCatalogReplaceResult, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionsV2ExclusionExpectation, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; +import { CHAT_ORIGIN_METADATA_KEY, CHAT_PROVIDER_DATA_METADATA_KEY, type IPersistedPeerChat } from '../../node/agentHostPeerChatStore.js'; +import { AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT, AGENT_HOST_CATALOG_PAYLOAD_VERSION, AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, type AgentHostCatalogData } from '../../node/agentHostCatalogProjection.js'; +import { AgentSessionRegistry, type IRegisteredSession } from '../../node/agentSessionRegistry.js'; import { AgentHostManagementService } from '../../node/agentHostManagementService.js'; -import { AGENT_HOST_TITLE_SOURCE_AUTO, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AUTO, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; import { MockAgent, ScriptedMockAgent } from './mockAgent.js'; import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; import { type ISessionEvent } from './copilotTestEvents.js'; @@ -66,6 +69,7 @@ import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { createTestAgentHostWorktreeIsolation, createTestAgentService, getTestAgentHostProviderService, getTestAgentHostWorktreeIsolation, getTestAgentServiceComposition, getTestAgentStateManager, registerTestAgentProvider, setTestAgentHostWorktreeIsolation } from './agentServiceTestUtils.js'; +import { readSessionArtifacts, SESSION_META_ARTIFACTS_KEY, SessionArtifactType, withSessionArtifacts } from '../../common/sessionArtifacts.js'; /** * Replace individual operations on an agent's chat surface, delegating every @@ -126,8 +130,9 @@ function discoveredChat(session: URI, external = true, modifiedTime = Date.now() }; } -function createPerSessionDataService(): { readonly service: ISessionDataService; readonly database: (session: URI) => TestSessionDatabase } { +function createPerSessionDataService(): { readonly service: ISessionDataService; readonly database: (session: URI) => TestSessionDatabase; readonly databaseOpens: string[]; readonly databaseIds: () => readonly string[] } { const databases = new Map(); + const databaseOpens: string[] = []; const database = (session: URI): TestSessionDatabase => { const key = session.toString(); let result = databases.get(key); @@ -140,16 +145,87 @@ function createPerSessionDataService(): { readonly service: ISessionDataService; return { service: { ...createSessionDataService(), - openDatabase: session => ({ object: database(session), dispose: () => { } }), + openDatabase: session => { + databaseOpens.push(session.toString()); + return { object: database(session), dispose: () => { } }; + }, tryOpenDatabase: async session => { + databaseOpens.push(session.toString()); const result = databases.get(session.toString()); return result ? { object: result, dispose: () => { } } : undefined; }, }, database, + databaseOpens, + databaseIds: () => [...databases.keys()], + }; +} + +/** Builds the durable catalog envelope a verified payload produces for `session`. */ +function catalogEnvelope(session: URI, data: AgentHostCatalogData, sessionGeneration = 'test-generation', sourceRevision = 1): IAgentHostDatabaseSessionV2Envelope { + const encoded = encodeAgentHostCatalogPayload(data); + if (!encoded.ok) { + throw new Error(encoded.error); + } + return { + session: session.toString(), + sessionGeneration, + sourceRevision, + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + payloadHash: encoded.value.payloadHash, + verified: true, + payload: encoded.value.payload, }; } +/** Mirrors the database's own derivation of the chat-backing marker from a stored payload. */ +function isChatBackingPayload(payload: string): boolean { + const decoded = decodeAgentHostCatalogPayload(payload); + return decoded.ok && decoded.value.data.isChatBacking === true; +} + +/** Reads a stored catalog row the way a downstream consumer would: through its payload. */ +function catalogDataOf(row: { readonly payload: string } | undefined): AgentHostCatalogData | undefined { + if (!row) { + return undefined; + } + const decoded = decodeAgentHostCatalogPayload(row.payload); + return decoded.ok ? decoded.value.data : undefined; +} + +async function seedVerifiedSessionV2(database: IAgentHostDatabase, sessionData: TestSessionDatabase, session: URI, external: boolean, isRead = true): Promise { + const provider = AgentSession.provider(session); + assert.ok(provider); + const envelope = catalogEnvelope(session, { + modifiedTime: 1, + summary: 'verified', + isRead, + isArchived: false, + workingDirectories: [], + chats: [{ uri: buildDefaultChatUri(session), order: 0, kind: 'default' }], + }, 'verified-generation', 0); + await database.registerSessionV2(session.toString(), { + provider, + startTime: 1, + source: external ? 'discovery' : 'restore', + }, { checkTombstone: true }); + await sessionData.setMetadataValuesAndCatalogSyncSnapshot({}, { + sessionGeneration: envelope.sessionGeneration, + sourceRevision: envelope.sourceRevision, + projectionVersion: envelope.payloadVersion, + payload: envelope.payload, + payloadHash: envelope.payloadHash, + state: 'pending', + }); + assert.strictEqual(await database.upsertSessionV2(envelope, undefined), 'applied'); + assert.strictEqual(await sessionData.acknowledgeCatalogSyncSnapshot({ + sessionGeneration: envelope.sessionGeneration, + sourceRevision: envelope.sourceRevision, + projectionVersion: envelope.payloadVersion, + payloadHash: envelope.payloadHash, + }), true); +} + function sessionConfigToChatOptions(config: IAgentCreateSessionConfig): IAgentCreateChatOptions { return { model: config.model, @@ -170,6 +246,21 @@ async function expectCreatedChat(result: Promise) return created; } +function matchesExclusionExpectation( + identity: IAgentHostDatabaseSession | undefined, + catalog: IAgentHostDatabaseSessionV2 | undefined, + expected: IAgentHostDatabaseSessionsV2ExclusionExpectation, +): boolean { + return identity?.provider === expected.identity?.provider + && identity?.startTime === expected.identity?.startTime + && identity?.modifiedTime === expected.identity?.modifiedTime + && identity?.external === expected.identity?.external + && identity?.source === expected.identity?.source + && catalog?.sessionGeneration === expected.catalog?.sessionGeneration + && catalog?.sourceRevision === expected.catalog?.sourceRevision + && catalog?.payloadHash === expected.catalog?.payloadHash; +} + async function createProvisionalChat(base: IAgentChats, chat: URI, context: URI | IAgentChatContext, options?: IAgentCreateChatOptions): Promise { const result = await base.createChat(chat, context, options); return result ? { ...result, provisional: true } : result; @@ -231,19 +322,40 @@ class TestCopilotApiService implements ICopilotApiService { class TransientRegistryWriteDatabase implements IAgentHostDatabase { private readonly _sessions = new Map(); + private readonly _sessionV2Registrations = new Map(); + private readonly _sessionsV2 = new Map(); private _backfilled = false; private readonly _providerBackfilled = new Set(); + private readonly _sessionsV2Backfilled = new Set(); + private readonly _sessionsV2Exclusions = new Map(); private readonly _tombstones = new Set(); private readonly _agentMergeEnabled = new Set(); + private readonly _sessionChats = new Map(); registryWriteAttempts = 0; private _remainingRegistryWriteFailures = 0; - private readonly _sessionsWithoutExternal = new Set(); + modifiedTimeBatchAttempts = 0; + private _remainingModifiedTimeBatchFailures = 0; + sessionChatCatalogReplaceAttempts = 0; + private _blockedSessionChatCatalogWrite: { readonly started: DeferredPromise; readonly release: DeferredPromise } | undefined; readonly externalUpdates: { session: string; external: boolean }[] = []; undefinedExternalListCalls = 0; + sessionV2UpsertAttempts = 0; + sessionV2ReconcileAttempts = 0; addSessionWithoutExternal(session: IAgentHostDatabaseSession): void { - this._sessions.set(session.session, session); - this._sessionsWithoutExternal.add(session.session); + this._sessionV2Registrations.set(session.session, { ...session, external: undefined }); + } + + addLegacySessionWithoutExternal(session: IAgentHostDatabaseSession): void { + this._sessions.set(session.session, { ...session, external: undefined }); + } + + setSessionV2PayloadReceipt(session: URI, payloadVersion: number, sessionGeneration: string): void { + const catalog = this._sessionsV2.get(session.toString()); + if (!catalog) { + throw new Error(`Missing test sessions_v2 row ${session.toString()}`); + } + this._sessionsV2.set(session.toString(), { ...catalog, payloadVersion, sessionGeneration }); } failRegistryWrites(count: number): void { @@ -251,6 +363,20 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._remainingRegistryWriteFailures = count; } + failModifiedTimeBatches(count: number): void { + this.modifiedTimeBatchAttempts = 0; + this._remainingModifiedTimeBatchFailures = count; + } + + blockNextSessionChatCatalogWrite(): { readonly started: DeferredPromise; readonly release: DeferredPromise } { + const blocked = { + started: new DeferredPromise(), + release: new DeferredPromise(), + }; + this._blockedSessionChatCatalogWrite = blocked; + return blocked; + } + async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { this._beforeWrite(); if (registerOptions.checkTombstone && this._tombstones.has(session)) { @@ -281,14 +407,17 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._beforeWrite(); this._tombstones.add(session); this._sessions.delete(session); + this._sessionV2Registrations.delete(session); + this._sessionsV2.delete(session); this._agentMergeEnabled.delete(session); + this._sessionChats.delete(session); } async updateSessionExternal(updates: readonly { readonly session: string; readonly external: boolean }[]): Promise { this.externalUpdates.push(...updates); for (const update of updates) { const session = this._sessions.get(update.session); - if (session && this._sessionsWithoutExternal.delete(update.session)) { + if (session && session.external === undefined) { this._sessions.set(update.session, { ...session, external: update.external, @@ -298,36 +427,39 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { } } + // Recency advances are not registry identity writes, so they are excluded + // from the identity-write accounting `registryWriteAttempts` reports. async updateSessionModifiedTime(session: string, modifiedTime: number): Promise { - this._beforeWrite(); + const current = this._sessionV2Registrations.get(session); + if (current && current.modifiedTime < modifiedTime) { + this._sessionV2Registrations.set(session, { ...current, modifiedTime }); + } const existing = this._sessions.get(session); if (!existing || existing.modifiedTime >= modifiedTime) { - return false; + return !!current && current.modifiedTime < modifiedTime; } this._sessions.set(session, { ...existing, modifiedTime }); return true; } async updateSessionModifiedTimes(updates: readonly { readonly session: string; readonly modifiedTime: number }[]): Promise { + this.modifiedTimeBatchAttempts++; + if (this._remainingModifiedTimeBatchFailures > 0) { + this._remainingModifiedTimeBatchFailures--; + throw new Error('transient modified-time batch failure'); + } for (const { session, modifiedTime } of updates) { - this._beforeWrite(); - const existing = this._sessions.get(session); - if (existing && Number.isFinite(modifiedTime) && existing.modifiedTime < modifiedTime) { - this._sessions.set(session, { ...existing, modifiedTime }); - } + await this.updateSessionModifiedTime(session, modifiedTime); } } async listSessions(): Promise { this.undefinedExternalListCalls++; - return [...this._sessions.values()].map(session => this._sessionsWithoutExternal.has(session.session) - ? { ...session, external: undefined } - : session); + return [...this._sessions.values()]; } async getSession(session: string): Promise { - const value = this._sessions.get(session); - return value && this._sessionsWithoutExternal.has(session) ? { ...value, external: undefined } : value; + return this._sessions.get(session); } async isSessionRegistryEmpty(): Promise { @@ -352,6 +484,44 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._providerBackfilled.add(provider); } + async isSessionsV2Backfilled(provider: string, projectionVersion: number): Promise { + return this._sessionsV2Backfilled.has(`${provider}:${projectionVersion}`); + } + + async markSessionsV2Backfilled(provider: string, projectionVersion: number): Promise { + this._beforeWrite(); + this._sessionsV2Backfilled.add(`${provider}:${projectionVersion}`); + } + + async markSessionsV2Excluded(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise { + this._beforeWrite(); + this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); + } + + async excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion, expected: IAgentHostDatabaseSessionsV2ExclusionExpectation): Promise<'excluded' | 'stale'> { + this._beforeWrite(); + if (!matchesExclusionExpectation(this._sessionV2Registrations.get(exclusion.session), await this.getSessionV2(exclusion.session), expected)) { + return 'stale'; + } + this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); + this._sessionV2Registrations.delete(exclusion.session); + this._sessionsV2.delete(exclusion.session); + return 'excluded'; + } + + async getSessionsV2Exclusion(provider: string, session: string): Promise { + return this._sessionsV2Exclusions.get(`${provider}:${session}`); + } + + async listSessionsV2Exclusions(provider: string): Promise { + return [...this._sessionsV2Exclusions.values()].filter(exclusion => exclusion.provider === provider); + } + + async clearSessionsV2Exclusion(provider: string, session: string): Promise { + this._beforeWrite(); + this._sessionsV2Exclusions.delete(`${provider}:${session}`); + } + async isSessionTombstoned(session: string): Promise { return this._tombstones.has(session); } @@ -366,6 +536,62 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._tombstones.delete(session); } + async registerRuntimeSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + this._beforeWrite(); + if (registerOptions.checkTombstone && this._tombstones.has(session)) { + return false; + } + const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; + const existing = this._sessionV2Registrations.get(session) ?? this._sessions.get(session); + const inserted = { session, provider, startTime, modifiedTime, external: source === 'discovery', source }; + const registration = source === 'explicit' + ? { ...inserted, startTime: existing?.startTime ?? startTime, modifiedTime: Math.max(existing?.modifiedTime ?? 0, modifiedTime) } + : existing && source === 'discovery' + ? { ...existing, external: existing.source === 'explicit' ? existing.external : true, source: existing.source === 'explicit' ? 'explicit' as const : 'discovery' as const } + : existing && source === 'restore' + ? { ...existing, external: false, source: existing.source === 'explicit' ? 'explicit' as const : 'restore' as const } + : existing ?? inserted; + this._sessionV2Registrations.set(session, registration); + this._sessions.set(session, registration); + this._sessionsV2Exclusions.delete(`${provider}:${session}`); + if (!registerOptions.checkTombstone) { + this._tombstones.delete(session); + } + return true; + } + + async unregisterRuntimeSession(session: string): Promise { + this._beforeWrite(); + this._sessionV2Registrations.delete(session); + this._sessionsV2.delete(session); + this._sessionChats.delete(session); + this._sessions.delete(session); + this._agentMergeEnabled.delete(session); + } + + async updateRuntimeSessionExternal(updates: readonly { readonly session: string; readonly external: boolean }[]): Promise { + this._beforeWrite(); + this.externalUpdates.push(...updates); + for (const update of updates) { + const registration = this._sessionV2Registrations.get(update.session); + if (registration && registration.external === undefined) { + const updated = { + ...registration, + external: update.external, + source: update.external ? 'discovery' as const : registration.source === 'explicit' ? 'explicit' as const : 'restore' as const, + }; + this._sessionV2Registrations.set(update.session, updated); + this._sessions.set(update.session, updated); + } + } + } + + async listRuntimeCompatibleSessionKeys(): Promise { + return [...new Set([...this._sessionV2Registrations.values(), ...this._sessions.values()] + .filter(session => !this._sessionsV2Exclusions.has(`${session.provider}:${session.session}`)) + .map(session => session.session))]; + } + async setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise { if (enabled) { this._agentMergeEnabled.add(session); @@ -378,6 +604,158 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { return [...this._agentMergeEnabled]; } + async registerSessionV2(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + this._beforeWrite(); + if (registerOptions.checkTombstone && this._tombstones.has(session)) { + return false; + } + const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; + const existing = this._sessionV2Registrations.get(session); + this._sessionV2Registrations.set(session, existing ?? { session, provider, startTime, modifiedTime, external: source === 'discovery', source }); + this._sessionsV2Exclusions.delete(`${provider}:${session}`); + if (!registerOptions.checkTombstone) { + this._tombstones.delete(session); + } + return true; + } + + async unregisterSessionV2(session: string): Promise { + this._beforeWrite(); + this._sessionV2Registrations.delete(session); + this._sessionsV2.delete(session); + } + + async updateSessionV2External(updates: readonly { readonly session: string; readonly external: boolean }[]): Promise { + this.externalUpdates.push(...updates); + for (const update of updates) { + const registration = this._sessionV2Registrations.get(update.session); + if (registration && registration.external === undefined) { + this._sessionV2Registrations.set(update.session, { + ...registration, + external: update.external, + source: update.external ? 'discovery' : registration.source, + }); + } + } + } + + async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { + this._beforeWrite(); + this.sessionV2ReconcileAttempts++; + const reconciled = { + ...legacy, + modifiedTime: Math.max(this._sessionV2Registrations.get(session)?.modifiedTime ?? legacy.modifiedTime, legacy.modifiedTime), + }; + this._sessionV2Registrations.set(session, reconciled); + const projection = this._sessionsV2.get(session); + if (projection) { + this._sessionsV2.set(session, { ...projection, ...reconciled }); + } + return reconciled; + } + + async getSessionV2Registration(session: string): Promise { + return this._sessionV2Registrations.get(session); + } + + async listSessionV2Registrations(): Promise { + this.undefinedExternalListCalls++; + return [...this._sessionV2Registrations.values()]; + } + + listSessionV2RegistrationsForImport(): Promise { + return this.listSessionV2Registrations(); + } + + async isSessionV2RegistryEmpty(): Promise { + return this._sessionV2Registrations.size === 0; + } + + async getSessionV2(session: string): Promise { return this._sessionsV2.get(session); } + async listSessionsV2(): Promise { return [...this._sessionsV2.values()]; } + async listSessionsV2Receipts(): Promise { + return [...this._sessionsV2.values()].map(({ payload, ...receipt }) => receipt); + } + async markSessionV2PayloadDirty(session: string): Promise { + const current = this._sessionsV2.get(session); + if (!current) { + return undefined; + } + const payloadDirty = current.payloadDirty + 1; + this._sessionsV2.set(session, { ...current, payloadDirty }); + return payloadDirty; + } + async getSessionV2PayloadDirty(session: string): Promise { + return this._sessionsV2.get(session)?.payloadDirty; + } + async markAllSessionsV2PayloadsDirty(): Promise { + for (const [session, current] of this._sessionsV2) { + this._sessionsV2.set(session, { ...current, payloadDirty: current.payloadDirty + 1 }); + } + } + async markSessionV2PayloadClean(session: string, expectedDirty: number): Promise { + const current = this._sessionsV2.get(session); + if (!current || current.payloadDirty !== expectedDirty) { + return false; + } + this._sessionsV2.set(session, { ...current, payloadDirty: 0 }); + return true; + } + async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { + this.sessionV2UpsertAttempts++; + const session = this._sessionV2Registrations.get(envelope.session); + if (!session) { + return 'missingSession'; + } + const current = this._sessionsV2.get(envelope.session); + if (current?.sessionGeneration !== expectedSessionGeneration) { + return 'generationMismatch'; + } + this._sessionsV2.set(envelope.session, { ...session, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload), payloadDirty: current?.payloadDirty ?? 1 }); + return 'applied'; + } + async getSessionChatCatalog(session: string): Promise { + return this._sessionChats.get(session); + } + async replaceSessionChatCatalog(session: string, chats: readonly IAgentHostDatabaseSessionChat[], expectedRevision: number | undefined): Promise { + this.sessionChatCatalogReplaceAttempts++; + const blocked = this._blockedSessionChatCatalogWrite; + if (blocked) { + this._blockedSessionChatCatalogWrite = undefined; + blocked.started.complete(); + await blocked.release.p; + } + if (this._tombstones.has(session)) { + return { status: 'tombstoned' }; + } + if (!this._sessionV2Registrations.has(session) && !this._sessions.has(session)) { + return { status: 'missingSession' }; + } + const current = this._sessionChats.get(session); + if (current?.revision !== expectedRevision) { + return { status: 'conflict' }; + } + const revision = (current?.revision ?? 0) + 1; + this._sessionChats.set(session, { revision, legacyMirroredRevision: current?.legacyMirroredRevision ?? 0, chats: [...chats] }); + return { status: 'applied', revision }; + } + async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number, payload?: string): Promise { + const current = this._sessionChats.get(session); + if (!current || current.revision !== expectedRevision) { + return false; + } + this._sessionChats.set(session, { ...current, legacyMirroredRevision: expectedRevision, ...(payload === undefined ? {} : { legacyMirroredPayload: payload }) }); + return true; + } + async recordSessionChatCatalogLegacyMirrorPayload(session: string, expectedRevision: number, payload: string): Promise { + const current = this._sessionChats.get(session); + if (!current || current.revision !== expectedRevision) { + return false; + } + this._sessionChats.set(session, { ...current, legacyMirroredPayload: payload }); + return true; + } + async close(): Promise { } dispose(): void { } @@ -393,10 +771,16 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { /** In-memory orchestrator database that two {@link AgentService} instances can share to simulate a host restart. */ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { private readonly _sessions = new Map(); + private readonly _sessionV2Registrations = new Map(); + private readonly _sessionsV2 = new Map(); private readonly _providerBackfilled = new Set(); + private readonly _sessionsV2Backfilled = new Set(); + private readonly _sessionsV2Exclusions = new Map(); private readonly _tombstones = new Set(); private readonly _agentMergeEnabled = new Set(); + private readonly _sessionChats = new Map(); private _backfilled = false; + catalogListCalls = 0; /** Test spies for the batched recency-write path. */ updateSessionModifiedTimesCalls = 0; lastModifiedTimesBatchSize = 0; @@ -424,28 +808,31 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { async tombstoneAndUnregisterSession(session: string): Promise { this._tombstones.add(session); this._sessions.delete(session); + this._sessionV2Registrations.delete(session); + this._sessionsV2.delete(session); this._agentMergeEnabled.delete(session); + this._sessionChats.delete(session); } async updateSessionExternal(): Promise { } async updateSessionModifiedTime(session: string, modifiedTime: number): Promise { - const existing = this._sessions.get(session); - if (!existing || existing.modifiedTime >= modifiedTime) { - return false; + let changed = false; + for (const sessions of [this._sessions, this._sessionV2Registrations]) { + const existing = sessions.get(session); + if (existing && existing.modifiedTime < modifiedTime) { + sessions.set(session, { ...existing, modifiedTime }); + changed = true; + } } - this._sessions.set(session, { ...existing, modifiedTime }); - return true; + return changed; } async updateSessionModifiedTimes(updates: readonly { readonly session: string; readonly modifiedTime: number }[]): Promise { this.updateSessionModifiedTimesCalls++; this.lastModifiedTimesBatchSize = updates.length; for (const { session, modifiedTime } of updates) { - const existing = this._sessions.get(session); - if (existing && Number.isFinite(modifiedTime) && existing.modifiedTime < modifiedTime) { - this._sessions.set(session, { ...existing, modifiedTime }); - } + await this.updateSessionModifiedTime(session, modifiedTime); } } @@ -477,6 +864,40 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { this._providerBackfilled.add(provider); } + async isSessionsV2Backfilled(provider: string, projectionVersion: number): Promise { + return this._sessionsV2Backfilled.has(`${provider}:${projectionVersion}`); + } + + async markSessionsV2Backfilled(provider: string, projectionVersion: number): Promise { + this._sessionsV2Backfilled.add(`${provider}:${projectionVersion}`); + } + + async markSessionsV2Excluded(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise { + this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); + } + + async excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion, expected: IAgentHostDatabaseSessionsV2ExclusionExpectation): Promise<'excluded' | 'stale'> { + if (!matchesExclusionExpectation(this._sessionV2Registrations.get(exclusion.session), await this.getSessionV2(exclusion.session), expected)) { + return 'stale'; + } + this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); + this._sessionV2Registrations.delete(exclusion.session); + this._sessionsV2.delete(exclusion.session); + return 'excluded'; + } + + async getSessionsV2Exclusion(provider: string, session: string): Promise { + return this._sessionsV2Exclusions.get(`${provider}:${session}`); + } + + async listSessionsV2Exclusions(provider: string): Promise { + return [...this._sessionsV2Exclusions.values()].filter(exclusion => exclusion.provider === provider); + } + + async clearSessionsV2Exclusion(provider: string, session: string): Promise { + this._sessionsV2Exclusions.delete(`${provider}:${session}`); + } + async isSessionTombstoned(session: string): Promise { return this._tombstones.has(session); } @@ -489,6 +910,35 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { this._tombstones.delete(session); } + async registerRuntimeSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + const registered = await this.registerSessionV2(session, sessionOptions, registerOptions); + if (registered) { + this._sessions.set(session, this._sessionV2Registrations.get(session)!); + } + return registered; + } + + async unregisterRuntimeSession(session: string): Promise { + await this.unregisterSessionV2(session); + await this.unregisterSession(session); + } + + async updateRuntimeSessionExternal(updates: readonly { readonly session: string; readonly external: boolean }[]): Promise { + await this.updateSessionV2External(updates); + for (const update of updates) { + const registration = this._sessionV2Registrations.get(update.session); + if (registration) { + this._sessions.set(update.session, registration); + } + } + } + + async listRuntimeCompatibleSessionKeys(): Promise { + return [...new Set([...this._sessionV2Registrations.values(), ...this._sessions.values()] + .filter(session => !this._sessionsV2Exclusions.has(`${session.provider}:${session.session}`)) + .map(session => session.session))]; + } + async setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise { if (enabled) { this._agentMergeEnabled.add(session); @@ -501,6 +951,155 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { return [...this._agentMergeEnabled]; } + async registerSessionV2(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + if (registerOptions.checkTombstone && this._tombstones.has(session)) { + return false; + } + const { provider, startTime, modifiedTime = startTime, source } = sessionOptions; + const existing = this._sessionV2Registrations.get(session); + this._sessionV2Registrations.set(session, existing ?? { session, provider, startTime, modifiedTime, external: source === 'discovery', source }); + this._sessionsV2Exclusions.delete(`${provider}:${session}`); + if (!registerOptions.checkTombstone) { + this._tombstones.delete(session); + } + return true; + } + + async unregisterSessionV2(session: string): Promise { + this._sessionV2Registrations.delete(session); + this._sessionsV2.delete(session); + this._sessionChats.delete(session); + } + + async updateSessionV2External(updates: readonly { readonly session: string; readonly external: boolean }[]): Promise { + for (const update of updates) { + const registration = this._sessionV2Registrations.get(update.session); + if (registration) { + this._sessionV2Registrations.set(update.session, { + ...registration, + external: update.external, + source: update.external ? 'discovery' : registration.source, + }); + } + } + } + + async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { + const reconciled = { + ...legacy, + modifiedTime: Math.max(this._sessionV2Registrations.get(session)?.modifiedTime ?? legacy.modifiedTime, legacy.modifiedTime), + }; + this._sessionV2Registrations.set(session, reconciled); + const projection = this._sessionsV2.get(session); + if (projection) { + this._sessionsV2.set(session, { ...projection, ...reconciled }); + } + return reconciled; + } + + async getSessionV2Registration(session: string): Promise { + return this._sessionV2Registrations.get(session); + } + + async listSessionV2Registrations(): Promise { + return [...this._sessionV2Registrations.values()]; + } + + listSessionV2RegistrationsForImport(): Promise { + return this.listSessionV2Registrations(); + } + + async isSessionV2RegistryEmpty(): Promise { + return this._sessionV2Registrations.size === 0; + } + + async getSessionV2(session: string): Promise { + this.catalogListCalls++; + return this._sessionsV2.get(session); + } + async listSessionsV2(): Promise { + this.catalogListCalls++; + return [...this._sessionsV2.values()]; + } + async listSessionsV2Receipts(): Promise { + this.catalogListCalls++; + return [...this._sessionsV2.values()].map(({ payload, ...receipt }) => receipt); + } + async markSessionV2PayloadDirty(session: string): Promise { + const current = this._sessionsV2.get(session); + if (!current) { + return undefined; + } + const payloadDirty = current.payloadDirty + 1; + this._sessionsV2.set(session, { ...current, payloadDirty }); + return payloadDirty; + } + async getSessionV2PayloadDirty(session: string): Promise { + return this._sessionsV2.get(session)?.payloadDirty; + } + async markAllSessionsV2PayloadsDirty(): Promise { + for (const [session, current] of this._sessionsV2) { + this._sessionsV2.set(session, { ...current, payloadDirty: current.payloadDirty + 1 }); + } + } + async markSessionV2PayloadClean(session: string, expectedDirty: number): Promise { + const current = this._sessionsV2.get(session); + if (!current || current.payloadDirty !== expectedDirty) { + return false; + } + this._sessionsV2.set(session, { ...current, payloadDirty: 0 }); + return true; + } + async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { + const session = this._sessionV2Registrations.get(envelope.session); + if (!session) { + return 'missingSession'; + } + const current = this._sessionsV2.get(envelope.session); + if (current?.sessionGeneration !== expectedSessionGeneration) { + return 'generationMismatch'; + } + if (current?.sessionGeneration === envelope.sessionGeneration && current.sourceRevision === envelope.sourceRevision) { + return current.payloadVersion === envelope.payloadVersion && current.payloadHash === envelope.payloadHash ? 'replayed' : 'conflict'; + } + this._sessionsV2.set(envelope.session, { ...session, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload), payloadDirty: current?.payloadDirty ?? 1 }); + return 'applied'; + } + async getSessionChatCatalog(session: string): Promise { + return this._sessionChats.get(session); + } + async replaceSessionChatCatalog(session: string, chats: readonly IAgentHostDatabaseSessionChat[], expectedRevision: number | undefined): Promise { + if (this._tombstones.has(session)) { + return { status: 'tombstoned' }; + } + if (!this._sessionV2Registrations.has(session) && !this._sessions.has(session)) { + return { status: 'missingSession' }; + } + const current = this._sessionChats.get(session); + if (current?.revision !== expectedRevision) { + return { status: 'conflict' }; + } + const revision = (current?.revision ?? 0) + 1; + this._sessionChats.set(session, { revision, legacyMirroredRevision: current?.legacyMirroredRevision ?? 0, chats: [...chats] }); + return { status: 'applied', revision }; + } + async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number, payload?: string): Promise { + const current = this._sessionChats.get(session); + if (!current || current.revision !== expectedRevision) { + return false; + } + this._sessionChats.set(session, { ...current, legacyMirroredRevision: expectedRevision, ...(payload === undefined ? {} : { legacyMirroredPayload: payload }) }); + return true; + } + async recordSessionChatCatalogLegacyMirrorPayload(session: string, expectedRevision: number, payload: string): Promise { + const current = this._sessionChats.get(session); + if (!current || current.revision !== expectedRevision) { + return false; + } + this._sessionChats.set(session, { ...current, legacyMirroredPayload: payload }); + return true; + } + async close(): Promise { } dispose(): void { } } @@ -531,6 +1130,15 @@ suite('AgentService (node dispatcher)', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); + test('starts catalog reconciliation in the background and exposes an awaitable idle hook', async () => { + registerTestAgentProvider(service, copilotAgent); + + const sessions = await service.listSessions(); + await service.whenCatalogReconciliationIdle(); + + assert.deepStrictEqual(sessions, []); + }); + suite('resolveAgentChatContext', () => { test('accepts configuration- and chat-scoped resources and rejects unrelated resources', () => { @@ -1418,7 +2026,7 @@ suite('AgentService (node dispatcher)', () => { const persistedBeforeMaterialize = await db.getMetadata(SESSION_META_FOLDER_PICKER_KEY); agent.materialize(session, [URI.file('/work/one'), URI.file('/work/two')]); - await timeout(0); + await creating.whenCatalogReconciliationIdle(); const reopened = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); getConfigurationService(reopened).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); @@ -1485,7 +2093,7 @@ suite('AgentService (node dispatcher)', () => { const persistedGitHubBefore = await db.getMetadata(META_GITHUB_STATE); agent.materialize(session, [URI.file('/work/materialized'), URI.file('/work/two')]); - await timeout(0); + await localService.whenCatalogReconciliationIdle(); assert.deepStrictEqual({ before, @@ -3005,21 +3613,71 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('marks the backing and rolls back creation when default-chat provider data cannot be persisted', async () => { - // N2: `_persistDefaultChatBacking`'s provider-data write and its - // backing-marker write must be independent — a provider-data - // write failure must not prevent (or roll back) the backing - // marker, since that marker is what keeps the backing session out - // of the top-level list. - class FailingProviderDataDatabase extends TestSessionDatabase { - override async setMetadata(key: string, value: string): Promise { - if (key === 'defaultChatProviderData') { - throw new Error('provider data write failed'); + test('returns an already-published session when initial catalog persistence fails and schedules repair', async () => { + class FailingInitialCatalogDatabase extends TestSessionDatabase { + failCatalogWrite = true; + + override async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + if (this.failCatalogWrite) { + throw new Error('initial catalog write failed'); } - return super.setMetadata(key, value); + return super.setMetadataValuesAndCatalogSyncSnapshot(values, snapshot); } } - class BackedDefaultChatAgent extends MockAgent { + const sessionDatabase = new FailingInitialCatalogDatabase(); + const orchestratorDatabase = new TransientRegistryWriteDatabase(); + const svc = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(sessionDatabase), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + orchestratorDatabase, + )); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + + const session = await svc.createSession({ provider: 'copilot' }); + const stateAfterFailure = getStateManager(svc).getSessionState(session.toString()); + sessionDatabase.failCatalogWrite = false; + await svc.whenCatalogReconciliationIdle(); + + assert.deepStrictEqual({ + statePublished: !!stateAfterFailure, + registered: (await svc.getRegisteredSessions()).map(resource => resource.toString()), + catalogRepaired: !!await orchestratorDatabase.getSessionV2(session.toString()), + providerDisposeCalls: agent.disposeSessionCalls.length, + }, { + statePublished: true, + registered: [session.toString()], + catalogRepaired: true, + providerDisposeCalls: 0, + }); + }); + + test('marks the backing and rolls back creation when default-chat provider data cannot be persisted', async () => { + // N2: `_persistDefaultChatBacking`'s provider-data write and its + // backing-marker write must be independent — a provider-data + // write failure must not prevent (or roll back) the backing + // marker, since that marker is what keeps the backing session out + // of the top-level list. + class FailingProviderDataDatabase extends TestSessionDatabase { + override async setMetadata(key: string, value: string): Promise { + if (key === CHAT_PROVIDER_DATA_METADATA_KEY) { + throw new Error('provider data write failed'); + } + return super.setMetadata(key, value); + } + } + class BackedDefaultChatAgent extends MockAgent { override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ createChat: async (chat, context, options) => { const result = await base.createChat(chat, context, options); @@ -3038,7 +3696,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ registered: await svc.getRegisteredSessions(), backingMarked: db.setMetadataCalls.some(c => c.key === 'peerChatBacking'), - providerDataPersisted: db.setMetadataCalls.some(c => c.key === 'defaultChatProviderData'), + providerDataPersisted: db.setMetadataCalls.some(c => c.key === CHAT_PROVIDER_DATA_METADATA_KEY), disposeCalls: agent.disposeSessionCalls.length, }, { registered: [], @@ -3062,6 +3720,151 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(copilotAgent.disposeSessionCalls.length, 1); }); + test('drains and fences peer-chat writes before deleting session data', async () => { + const orchestratorDatabase = new TransientRegistryWriteDatabase(); + const metadataDatabase = new TestSessionDatabase(); + const baseSessionDataService = createSessionDataService(metadataDatabase); + const deleted = new Set(); + const recreated: string[] = []; + const sessionDataService: ISessionDataService = { + ...baseSessionDataService, + openDatabase: resource => { + if (deleted.has(resource.toString())) { + recreated.push(resource.toString()); + } + return baseSessionDataService.openDatabase(resource); + }, + deleteSessionData: async resource => { + deleted.add(resource.toString()); + }, + }; + class ChatDataDuringDisposalAgent extends MockAgent { + private readonly _chatData = new Emitter(); + override readonly onDidChangeChatData = this._chatData.event; + peerChat: URI | undefined; + + override async createChat(): Promise { } + + fireChatData(chat: URI, providerData: string): void { + this._chatData.fire({ chat, providerData }); + } + + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + disposeChat: async (chat, context) => { + if (chat.toString() === this.peerChat?.toString()) { + this.fireChatData(chat, 'queued-during-disposal'); + } + await base.disposeChat(chat, context); + }, + })); + } + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, orchestratorDatabase)); + const agent = disposables.add(new ChatDataDuringDisposalAgent('copilot')); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: 'copilot' }); + const peer = URI.parse(buildChatUri(session, 'peer')); + agent.peerChat = peer; + await svc.createChat(session, peer); + const blocked = orchestratorDatabase.blockNextSessionChatCatalogWrite(); + agent.fireChatData(peer, 'in-flight'); + await blocked.started.p; + + let deletionComplete = false; + const deletion = svc.disposeSession(session).then(() => { deletionComplete = true; }); + await timeout(0); + assert.strictEqual(deletionComplete, false); + blocked.release.complete(); + await deletion; + + assert.deepStrictEqual({ + replaceAttempts: orchestratorDatabase.sessionChatCatalogReplaceAttempts, + catalog: await orchestratorDatabase.getSessionChatCatalog(session.toString()), + deleted: [...deleted].sort(), + recreated, + }, { + replaceAttempts: 2, + catalog: undefined, + deleted: [peer.toString(), buildDefaultChatUri(session), session.toString()].sort(), + recreated: [], + }); + }); + + test('drains catalog synchronization and drops newly queued work before deleting session data', async () => { + class RecordingCatalogDatabase extends TestSessionDatabase { + readonly catalogTitles: string[] = []; + + override async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + if (values[SESSION_CUSTOM_TITLE_KEY]) { + this.catalogTitles.push(values[SESSION_CUSTOM_TITLE_KEY]); + } + return super.setMetadataValuesAndCatalogSyncSnapshot(values, snapshot); + } + } + + const database = new RecordingCatalogDatabase(); + const baseSessionDataService = createSessionDataService(database); + const deleted = new Set(); + const recreated: string[] = []; + const sessionDataService: ISessionDataService = { + ...baseSessionDataService, + openDatabase: resource => { + if (deleted.has(resource.toString())) { + recreated.push(resource.toString()); + } + return baseSessionDataService.openDatabase(resource); + }, + deleteSessionData: async resource => { + deleted.add(resource.toString()); + }, + }; + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + registerTestAgentProvider(svc, copilotAgent); + const session = await svc.createSession({ provider: 'copilot' }); + await svc.whenCatalogReconciliationIdle(); + const initialCatalogWrites = database.catalogTitles.length; + const internals = svc as unknown as { + _catalogSyncService: { + isSessionDeletionFenced(session: URI): boolean; + runExclusive(session: URI, operation: () => Promise): Promise; + }; + _queueCatalogSync(session: URI, metadataOverrides: Readonly>): void; + }; + + const blockerStarted = new DeferredPromise(); + const releaseBlocker = new DeferredPromise(); + const blocker = internals._catalogSyncService.runExclusive(session, async () => { + blockerStarted.complete(); + await releaseBlocker.p; + }); + await blockerStarted.p; + internals._queueCatalogSync(session, { [SESSION_CUSTOM_TITLE_KEY]: 'in-flight' }); + let deletionComplete = false; + const deletion = svc.disposeSession(session).then(() => { deletionComplete = true; }); + for (let i = 0; i < 50 && !internals._catalogSyncService.isSessionDeletionFenced(session); i++) { + await timeout(0); + } + internals._queueCatalogSync(session, { [SESSION_CUSTOM_TITLE_KEY]: 'fenced' }); + await timeout(0); + const deletionWaited = !deletionComplete; + releaseBlocker.complete(); + await Promise.all([blocker, deletion]); + await timeout(0); + + assert.deepStrictEqual({ + deletionWaited, + fencedAfterDeletion: internals._catalogSyncService.isSessionDeletionFenced(session), + catalogTitles: database.catalogTitles.slice(initialCatalogWrites), + sessionDataDeleted: deleted.has(session.toString()), + recreated, + }, { + deletionWaited: true, + fencedAfterDeletion: false, + catalogTitles: ['in-flight'], + sessionDataDeleted: true, + recreated: [], + }); + }); + test('is a no-op for unknown sessions', async () => { registerTestAgentProvider(service, copilotAgent); const unknownSession = URI.from({ scheme: 'unknown', path: '/nope' }); @@ -3097,7 +3900,7 @@ suite('AgentService (node dispatcher)', () => { await svc.disposeSession(session); - assert.deepStrictEqual(order, ['prepareSessionDeletion', 'deleteSessionData', 'removeSessionWorktree:file:///worktree']); + assert.deepStrictEqual(order, ['prepareSessionDeletion', 'deleteSessionData', 'deleteSessionData', 'removeSessionWorktree:file:///worktree']); }); test('preserves session data when worktree metadata cannot be read', async () => { @@ -3174,7 +3977,7 @@ suite('AgentService (node dispatcher)', () => { registryWriteAttempts: 3, registeredSessions: [], hasState: false, - deleteSessionDataCalls: 1, + deleteSessionDataCalls: 2, removeWorktreeCalls: 1, }); }); @@ -3185,11 +3988,11 @@ suite('AgentService (node dispatcher)', () => { suite('aggregation', () => { class TimedExternalAgent extends MockAgent { - readonly catalog = new Map(); + readonly catalog = new Map(); - addSession(id: string, modifiedTime: number, _meta?: IAgentSessionMetadata['_meta']): URI { + addSession(id: string, modifiedTime: number, _meta?: IAgentSessionMetadata['_meta'], summary?: string): URI { const session = AgentSession.uri(this.id, id); - this.catalog.set(id, { session, modifiedTime, _meta }); + this.catalog.set(id, { session, modifiedTime, summary, _meta }); (this as unknown as { _sessions: Map })._sessions.set(id, session); return session; } @@ -3199,6 +4002,7 @@ suite('AgentService (node dispatcher)', () => { chat: URI.parse(buildDefaultChatUri(entry.session)), startTime: entry.modifiedTime, modifiedTime: entry.modifiedTime, + ...(entry.summary ? { summary: entry.summary } : {}), ...(entry._meta ? { _meta: entry._meta } : {}), })); } @@ -3206,8 +4010,58 @@ suite('AgentService (node dispatcher)', () => { override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { const session = resolveAgentChatContext(context, chat).configurationResource; const entry = this.catalog.get(AgentSession.id(session)); - return entry ? { chat, startTime: entry.modifiedTime, modifiedTime: entry.modifiedTime, ...(entry._meta ? { _meta: entry._meta } : {}) } : undefined; + return entry ? { chat, startTime: entry.modifiedTime, modifiedTime: entry.modifiedTime, ...(entry.summary ? { summary: entry.summary } : {}), ...(entry._meta ? { _meta: entry._meta } : {}) } : undefined; + } + + // The catalog is now read back for listing, so the session-scoped + // metadata a real provider reports must agree with the chat-scoped one. + override async getSessionMetadata(session: URI): Promise { + const entry = this.catalog.get(AgentSession.id(session)); + return entry ? { session, startTime: entry.modifiedTime, modifiedTime: entry.modifiedTime, ...(entry.summary ? { summary: entry.summary } : {}), ...(entry._meta ? { _meta: entry._meta } : {}) } : undefined; + } + } + + class CentralCatalogDatabase extends TestAgentHostOrchestratorDatabase { + private readonly _catalogs = new Map(); + + setCatalog(session: URI, data: AgentHostCatalogData): void { + this._catalogs.set(session.toString(), catalogEnvelope(session, data)); + } + + override async getSessionV2(session: string): Promise { + const envelope = this._catalogs.get(session); + const registered = await this.getSessionV2Registration(session); + return envelope && registered + ? { ...registered, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload), payloadDirty: 0 } + : undefined; + } + } + + class CountingMetadataAgent extends TimedExternalAgent { + metadataCalls: string[] = []; + prewarmCalls = 0; + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + this.metadataCalls.push(resolveAgentChatContext(context, chat).configurationResource.toString()); + return super.getChatMetadata(chat, context); } + + async prewarmSessionMetadata() { + this.prewarmCalls++; + return toDisposable(() => { }); + } + } + + function centralData(modifiedTime: number, summary: string, ehcliAdoptable = false): AgentHostCatalogData { + return { + modifiedTime, + summary, + isRead: false, + isArchived: false, + ...(ehcliAdoptable ? { _meta: { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true } } : {}), + workingDirectories: [], + chats: [], + }; } class ControlledDiscoveryAgent extends TimedExternalAgent { @@ -3227,7 +4081,7 @@ suite('AgentService (node dispatcher)', () => { } } - function createExternalSessionService(sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase, copilotApiService?: ICopilotApiService, storageResource?: URI): AgentService { + function createExternalSessionService(sessionDataService = createPerSessionDataService().service, orchestratorDatabase?: IAgentHostDatabase, copilotApiService?: ICopilotApiService, storageResource?: URI): AgentService { return disposables.add(createTestAgentService( new NullLogService(), fileService, @@ -3243,6 +4097,32 @@ suite('AgentService (node dispatcher)', () => { undefined, storageResource, orchestratorDatabase, + undefined, + undefined, + { + schedule: (callback, delay) => delay >= 5 * 60 * 1000 + ? toDisposable(() => { }) + : disposableTimeout(callback, delay), + }, + )); + } + + function createCentralCatalogService(sessionDataService: ISessionDataService, orchestratorDatabase: IAgentHostDatabase): AgentService { + return disposables.add(createTestAgentService( + new NullLogService(), + fileService, + sessionDataService, + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + orchestratorDatabase, )); } @@ -3427,7 +4307,24 @@ suite('AgentService (node dispatcher)', () => { } async function waitForSessionListReconciliation(service: AgentService): Promise { - await (service as unknown as { _sessionListReconciliation: Promise })._sessionListReconciliation; + const internal = service as unknown as { _sessionListReconciliation: Promise }; + let pending: Promise; + do { + pending = internal._sessionListReconciliation; + await pending; + } while (pending !== internal._sessionListReconciliation); + } + + async function waitForInitialProviderMigration(service: AgentService, provider: IAgent): Promise { + const internal = service as unknown as { + _initialProviderMigrations: Map>; + _providerMigrations: Map }>; + }; + await internal._initialProviderMigrations.get(provider.id); + await service.whenCatalogReconciliationIdle(); + while (internal._providerMigrations.has(provider.id)) { + await internal._providerMigrations.get(provider.id)?.promise; + } } function exposeListedSessions(service: AgentService, sessions: readonly IAgentSessionMetadata[]): void { @@ -3462,128 +4359,507 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions.length, 1); }); - test('listSessions discovers provider-native sessions as external and restore preserves provenance', async () => { - const db = new TestSessionDatabase(); - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); - const agent = new MockAgent('copilot'); - disposables.add(toDisposable(() => agent.dispose())); - - // Simulate a provider-native session that predates host registration. - const external = AgentSession.uri('copilot', 'external-session'); - (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(external), external); + test('central list uses eligible catalogs and suppresses chat backing with zero legacy reads', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const session = AgentSession.uri('copilot', 'central-only'); + await orchestratorDatabase.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + orchestratorDatabase.setCatalog(session, centralData(20, 'Central')); + const backingSession = AgentSession.uri('copilot', 'central-backing'); + await orchestratorDatabase.registerSessionV2(backingSession.toString(), { + provider: 'copilot', + startTime: 11, + source: 'explicit', + }, { checkTombstone: false }); + orchestratorDatabase.setCatalog(backingSession, { ...centralData(21, 'Backing'), isChatBacking: true }); + let databaseOpens = 0; + const sessionDataService: ISessionDataService = { + ...createSessionDataService(), + tryOpenDatabase: async () => { + databaseOpens++; + throw new Error('central list must not open session.db'); + }, + }; + const svc = createCentralCatalogService(sessionDataService, orchestratorDatabase); + await svc.whenCatalogReconciliationIdle(); + databaseOpens = 0; + const agent = disposables.add(new CountingMetadataAgent('copilot')); registerTestAgentProvider(svc, agent); + await waitForInitialProviderMigration(svc, agent); + agent.metadataCalls = []; + databaseOpens = 0; - const listed = new Set((await svc.listSessions()).map(s => s.session.toString())); - assert.deepStrictEqual(listed, new Set([external.toString()])); - assert.strictEqual(readSessionExternal((await svc.listSessions())[0]._meta), true); - assert.strictEqual(await db.getMetadata(AH_META_IS_READ_DB_KEY), 'true'); - await svc.restoreSession(external); + const listed = await svc.listSessions(); - const registered = await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.list(); - assert.deepStrictEqual(registered.map(entry => ({ - session: entry.session.toString(), - external: entry.external, - source: entry.source, - })), [{ session: external.toString(), external: true, source: 'discovery' }]); + assert.deepStrictEqual({ + sessions: listed.map(metadata => ({ session: metadata.session.toString(), title: metadata.summary })), + providerMetadataCalls: agent.metadataCalls, + providerPrewarmCalls: agent.prewarmCalls, + sessionDatabaseOpens: databaseOpens, + }, { + sessions: [{ session: session.toString(), title: 'Central' }], + providerMetadataCalls: [], + providerPrewarmCalls: 0, + sessionDatabaseOpens: 0, + }); }); - test('discovery keeps a host-created session internal when the provider reports it as external', async () => { - const sessionData = createPerSessionDataService(); - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); - const agent = disposables.add(new MockAgent('copilot')); + test('recency discovery invalidates an overlapping central list', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const session = AgentSession.uri('copilot', 'recency-overlap'); + await orchestratorDatabase.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 10, + modifiedTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + orchestratorDatabase.setCatalog(session, centralData(10, 'Central')); + const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); + await svc.whenCatalogReconciliationIdle(); + const agent = disposables.add(new CountingMetadataAgent('copilot')); registerTestAgentProvider(svc, agent); - await svc.listSessions(); - const hostCreated = AgentSession.uri('copilot', 'host-created'); - const genuineExternal = AgentSession.uri('copilot', 'genuine-external'); - await sessionData.database(hostCreated).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'false'); + await waitForInitialProviderMigration(svc, agent); - await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [ - discoveredChat(hostCreated), - discoveredChat(genuineExternal), - ]); + const snapshotRead = new DeferredPromise(); + const releaseSnapshot = new DeferredPromise(); + const internal = svc as unknown as { + _listRegisteredSessions(): Promise; + _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise; + _computeSessions(mode: AgentHostExternalSessionsMode, epoch?: number): Promise; + }; + const originalComputeSessions = internal._computeSessions.bind(svc); + let listComputations = 0; + internal._computeSessions = (mode, epoch) => { + listComputations++; + return originalComputeSessions(mode, epoch); + }; + const originalListRegisteredSessions = internal._listRegisteredSessions.bind(svc); + let blockFirstRead = true; + internal._listRegisteredSessions = async () => { + const result = await originalListRegisteredSessions(); + if (blockFirstRead) { + blockFirstRead = false; + snapshotRead.complete(); + await releaseSnapshot.p; + } + return result; + }; - assert.deepStrictEqual( - (await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.list()).map(entry => ({ - session: entry.session.toString(), - external: entry.external, - source: entry.source, - })).sort((a, b) => a.session.localeCompare(b.session)), - [ - { session: genuineExternal.toString(), external: true, source: 'discovery' }, - { session: hostCreated.toString(), external: false, source: 'restore' }, - ].sort((a, b) => a.session.localeCompare(b.session)), - ); + const first = svc.listSessions(); + await snapshotRead.p; + await internal._registerDiscoveredChats(agent, [discoveredChat(session, false, 20)]); + const second = svc.listSessions(); + releaseSnapshot.complete(); + + assert.deepStrictEqual({ + first: (await first)[0]?.modifiedTime, + second: (await second)[0]?.modifiedTime, + listComputations, + }, { + first: 20, + second: 20, + listComputations: 2, + }); }); - test('rediscovery advances recency without overwriting durable unread state for an existing external session', async () => { - const db = new TestSessionDatabase(); - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - const agent = disposables.add(new MockAgent('copilot')); - const session = AgentSession.uri('copilot', 'rediscovered-external'); - (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + test('central list keeps the startup-frozen adoptable gate without provider or session database reads', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const session = AgentSession.uri('copilot', 'central-adoptable'); + await orchestratorDatabase.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + orchestratorDatabase.setCatalog(session, centralData(20, 'Adoptable', true)); + let databaseOpens = 0; + const sessionDataService: ISessionDataService = { + ...createSessionDataService(), + tryOpenDatabase: async () => { + databaseOpens++; + throw new Error('eligible central list must not open session.db'); + }, + }; + const svc = createCentralCatalogService(sessionDataService, orchestratorDatabase); + await svc.whenCatalogReconciliationIdle(); + databaseOpens = 0; + const agent = disposables.add(new CountingMetadataAgent('copilot')); registerTestAgentProvider(svc, agent); - await svc.listSessions(); - await db.setMetadata(AH_META_IS_READ_DB_KEY, ''); - const rediscoveredModifiedTime = Date.now() + 60_000; + await waitForInitialProviderMigration(svc, agent); + agent.metadataCalls = []; + databaseOpens = 0; - await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [discoveredChat(session, true, rediscoveredModifiedTime)]); - const registered = await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.get(session); + const whileDisabled = await svc.listSessions(); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + (svc as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); + const whileEnabled = await svc.listSessions(); + orchestratorDatabase.setCatalog(session, centralData(20, 'Adopted')); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); + (svc as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); + const afterAdoption = await svc.listSessions(); assert.deepStrictEqual({ - isRead: await db.getMetadata(AH_META_IS_READ_DB_KEY), - modifiedTime: registered?.modifiedTime, + whileDisabled: whileDisabled.length, + whileEnabled: whileEnabled.map(metadata => metadata.summary), + afterAdoption: afterAdoption.map(metadata => metadata.summary), + providerMetadataCalls: agent.metadataCalls, + sessionDatabaseOpens: databaseOpens, }, { - isRead: '', - modifiedTime: rediscoveredModifiedTime, + whileDisabled: 0, + whileEnabled: [], + afterAdoption: ['Adopted'], + providerMetadataCalls: [], + sessionDatabaseOpens: 0, }); }); - test('discovery batches recency advances: unchanged times write nothing, advances produce one batch and one invalidation', async () => { - const orchestratorDb = new TestAgentHostOrchestratorDatabase(); - const svc = createExternalSessionService(createSessionDataService(), orchestratorDb); - const agent = disposables.add(new MockAgent('copilot')); - const a = AgentSession.uri('copilot', 'batch-a'); - const b = AgentSession.uri('copilot', 'batch-b'); - for (const s of [a, b]) { - (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(s), s); + test('central fallback accesses provider and session database only for the ineligible session', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const centralSession = AgentSession.uri('copilot', 'eligible'); + const fallbackSession = AgentSession.uri('copilot', 'fallback'); + for (const session of [centralSession, fallbackSession]) { + await orchestratorDatabase.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); } + orchestratorDatabase.setCatalog(centralSession, centralData(30, 'Central')); + const databaseOpens: string[] = []; + const fallbackDatabase = new TestSessionDatabase(); + const devContainerWorktree = { version: 1, handle: '00000000-0000-4000-8000-000000000001' }; + await fallbackDatabase.setMetadata(AH_META_DEV_CONTAINER_WORKTREE_DB_KEY, JSON.stringify(devContainerWorktree)); + const baseSessionDataService = createSessionDataService(fallbackDatabase); + const sessionDataService: ISessionDataService = { + ...baseSessionDataService, + tryOpenDatabase: async session => { + databaseOpens.push(session.toString()); + return baseSessionDataService.tryOpenDatabase(session); + }, + }; + const svc = createCentralCatalogService(sessionDataService, orchestratorDatabase); + await svc.whenCatalogReconciliationIdle(); + const agent = disposables.add(new CountingMetadataAgent('copilot')); + agent.addSession('eligible', 30); + agent.addSession('fallback', 25); registerTestAgentProvider(svc, agent); - const register = (chats: readonly IAgentDiscoveredChat[]) => (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, chats); - const epoch = () => (svc as unknown as { _registryEpoch: number })._registryEpoch; + await waitForInitialProviderMigration(svc, agent); + await svc.whenCatalogReconciliationIdle(); + agent.metadataCalls = []; + databaseOpens.length = 0; + + const listed = await svc.listSessions(); - // Seed both sessions at a fixed, high baseline so the assertions below are - // independent of any earlier auto-registration timestamp. - const base = Date.now() + 1_000_000; - await register([discoveredChat(a, false, base), discoveredChat(b, false, base)]); + assert.deepStrictEqual({ + sessions: listed.map(metadata => metadata.session.toString()), + devContainerWorktree: listed.find(metadata => metadata.session.toString() === fallbackSession.toString())?._meta?.[AH_META_DEV_CONTAINER_WORKTREE_DB_KEY], + providerMetadataCalls: agent.metadataCalls, + providerPrewarmCalls: agent.prewarmCalls, + sessionDatabaseOpenSessions: [...new Set(databaseOpens)], + sessionDatabaseOpenCount: databaseOpens.length, + }, { + sessions: [centralSession.toString(), fallbackSession.toString()], + devContainerWorktree, + providerMetadataCalls: [fallbackSession.toString()], + providerPrewarmCalls: 1, + sessionDatabaseOpenSessions: [buildDefaultChatUri(fallbackSession), fallbackSession.toString()], + sessionDatabaseOpenCount: 4, + }); + }); - // Rediscover with UNCHANGED timestamps -> no batched write, no invalidation. - const batchesBeforeNoop = orchestratorDb.updateSessionModifiedTimesCalls; - const epochBeforeNoop = epoch(); - await register([discoveredChat(a, false, base), discoveredChat(b, false, base)]); + test('central list drops an ineligible row whose fallback has no provider and lists eligible rows without a session database', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const eligible = AgentSession.uri('copilot', 'provider-unavailable'); + const ineligible = AgentSession.uri('copilot', 'missing-catalog'); + for (const session of [eligible, ineligible]) { + await orchestratorDatabase.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + } + orchestratorDatabase.setCatalog(eligible, centralData(40, 'Available centrally')); + let databaseOpens = 0; + const sessionDataService: ISessionDataService = { + ...createSessionDataService(), + tryOpenDatabase: async () => { + databaseOpens++; + return undefined; + }, + }; + const svc = createCentralCatalogService(sessionDataService, orchestratorDatabase); + await svc.whenCatalogReconciliationIdle(); + databaseOpens = 0; - // Rediscover with NEWER timestamps for both -> exactly one batch and one invalidation. - const batchesBeforeAdvance = orchestratorDb.updateSessionModifiedTimesCalls; - const epochBeforeAdvance = epoch(); - await register([discoveredChat(a, false, base + 1000), discoveredChat(b, false, base + 1000)]); + const listed = await svc.listSessions(); assert.deepStrictEqual({ - noopBatches: batchesBeforeAdvance - batchesBeforeNoop, - noopInvalidations: epochBeforeAdvance - epochBeforeNoop, - advanceBatches: orchestratorDb.updateSessionModifiedTimesCalls - batchesBeforeAdvance, - advanceInvalidations: epoch() - epochBeforeAdvance, - advanceBatchSize: orchestratorDb.lastModifiedTimesBatchSize, + sessions: listed.map(metadata => metadata.session.toString()), + sessionDatabaseOpens: databaseOpens, }, { - noopBatches: 0, - noopInvalidations: 0, - advanceBatches: 1, - advanceInvalidations: 1, - advanceBatchSize: 2, + sessions: [eligible.toString()], + sessionDatabaseOpens: 0, }); }); + test('central list applies the same live state overlay as legacy listing', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: 'copilot' }); + await svc.whenCatalogReconciliationIdle(); + orchestratorDatabase.setCatalog(session, { + ...centralData(20, 'Persisted title'), + workingDirectories: ['file:///persisted'], + changes: { files: 1 }, + }); + getStateManager(svc).dispatchServerAction(session.toString(), { + type: ActionType.SessionTitleChanged, + title: 'Live title', + }); + getStateManager(svc).dispatchServerAction(session.toString(), { + type: ActionType.SessionMetaChanged, + _meta: withSessionGitState(undefined, { branchName: 'live-branch' }), + }); - testWithExternalSessionClock('discovery does not ingest external sessions older than 30 days', async () => { + const [listed] = await svc.listSessions(); + + assert.deepStrictEqual({ + title: listed.summary, + workingDirectories: listed.workingDirectories?.map(directory => directory.toString()), + changes: listed.changes, + git: readSessionGitState(listed._meta), + }, { + title: 'Live title', + workingDirectories: ['file:///persisted'], + changes: { files: 1 }, + git: { branchName: 'live-branch' }, + }); + }); + + test('central list preserves registry ordering and recent external-session limits', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const now = Date.now(); + const sessions: URI[] = []; + for (let index = 0; index < 12; index++) { + const session = AgentSession.uri('copilot', `external-${index}`); + sessions.push(session); + await orchestratorDatabase.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: index, + source: 'discovery', + }, { checkTombstone: false }); + orchestratorDatabase.setCatalog(session, centralData(now - index, `Session ${index}`)); + } + const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); + await svc.whenCatalogReconciliationIdle(); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); + await waitForSessionListReconciliation(svc); + + const listed = await svc.listSessions(); + + assert.deepStrictEqual( + listed.map(metadata => metadata.session.toString()), + sessions.slice(0, 2).map(session => session.toString()), + ); + }); + + test('central fallback returns without waiting for scheduled reconciliation', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const session = AgentSession.uri('copilot', 'repair-later'); + await orchestratorDatabase.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); + await svc.whenCatalogReconciliationIdle(); + const agent = disposables.add(new CountingMetadataAgent('copilot')); + agent.addSession('repair-later', 20); + registerTestAgentProvider(svc, agent); + await timeout(0); + await svc.whenCatalogReconciliationIdle(); + const reconciliationStarted = new DeferredPromise(); + const reconciliation = (svc as unknown as { _catalogReconciliationService: { start(): void } })._catalogReconciliationService; + reconciliation.start = () => reconciliationStarted.complete(); + + const listed = await svc.listSessions(); + assert.deepStrictEqual({ + listed: listed.length, + reconciliationStartedBeforeReturn: reconciliationStarted.isSettled, + }, { + listed: 1, + reconciliationStartedBeforeReturn: false, + }); + await reconciliationStarted.p; + }); + + test('central list coalesces repeated lists onto one in-flight catalog read', async () => { + const firstReadStarted = new DeferredPromise(); + const releaseFirstRead = new DeferredPromise(); + class DeferredCatalogDatabase extends TestAgentHostOrchestratorDatabase { + activeCatalogReads = 0; + deferActiveCatalogReads = false; + + override async getSessionV2(): Promise { + if (this.deferActiveCatalogReads) { + this.activeCatalogReads++; + if (this.activeCatalogReads === 1) { + firstReadStarted.complete(); + await releaseFirstRead.p; + } + } + return undefined; + } + } + const orchestratorDatabase = new DeferredCatalogDatabase(); + const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + await svc.createSession({ provider: 'copilot' }); + await svc.whenCatalogReconciliationIdle(); + orchestratorDatabase.deferActiveCatalogReads = true; + + const blocked = svc.listSessions(); + await firstReadStarted.p; + const repeated = Promise.all([svc.listSessions(), svc.listSessions(), svc.listSessions()]); + const readsWhileBlocked = orchestratorDatabase.activeCatalogReads; + releaseFirstRead.complete(); + + assert.deepStrictEqual({ + readsWhileBlocked, + blockedListCount: (await blocked).length, + repeatedListCounts: (await repeated).map(list => list.length), + activeCatalogReads: orchestratorDatabase.activeCatalogReads, + }, { + readsWhileBlocked: 1, + blockedListCount: 1, + repeatedListCounts: [1, 1, 1], + activeCatalogReads: 1, + }); + }); + + test('listSessions discovers provider-native sessions as external and restore preserves provenance', async () => { + const db = new TestSessionDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + + // Simulate a provider-native session that predates host registration. + const external = AgentSession.uri('copilot', 'external-session'); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(external), external); + registerTestAgentProvider(svc, agent); + + const listed = new Set((await svc.listSessions()).map(s => s.session.toString())); + assert.deepStrictEqual(listed, new Set([external.toString()])); + assert.strictEqual(readSessionExternal((await svc.listSessions())[0]._meta), true); + assert.strictEqual(await db.getMetadata(AH_META_IS_READ_DB_KEY), 'true'); + await svc.restoreSession(external); + + const registered = await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.list(); + assert.deepStrictEqual(registered.map(entry => ({ + session: entry.session.toString(), + external: entry.external, + source: entry.source, + })), [{ session: external.toString(), external: true, source: 'discovery' }]); + }); + + test('discovery keeps a host-created session internal when the provider reports it as external', async () => { + const sessionData = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + await svc.listSessions(); + const hostCreated = AgentSession.uri('copilot', 'host-created'); + const genuineExternal = AgentSession.uri('copilot', 'genuine-external'); + await sessionData.database(hostCreated).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'false'); + + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [ + discoveredChat(hostCreated), + discoveredChat(genuineExternal), + ]); + + assert.deepStrictEqual( + (await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.list()).map(entry => ({ + session: entry.session.toString(), + external: entry.external, + source: entry.source, + })).sort((a, b) => a.session.localeCompare(b.session)), + [ + { session: genuineExternal.toString(), external: true, source: 'discovery' }, + { session: hostCreated.toString(), external: false, source: 'restore' }, + ].sort((a, b) => a.session.localeCompare(b.session)), + ); + }); + + test('rediscovery advances recency without overwriting durable unread state for an existing external session', async () => { + const db = new TestSessionDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + const agent = disposables.add(new TimedExternalAgent('copilot')); + const session = agent.addSession('rediscovered-external', Date.now(), undefined, 'Before rediscovery'); + registerTestAgentProvider(svc, agent); + await svc.listSessions(); + await db.setMetadata(AH_META_IS_READ_DB_KEY, ''); + const rediscoveredModifiedTime = Date.now() + 60_000; + agent.catalog.set(AgentSession.id(session), { session, modifiedTime: rediscoveredModifiedTime, summary: 'After rediscovery' }); + + const rediscovered = await agent.listExternalChats(); + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, rediscovered.map(metadata => ({ ...metadata, external: true }))); + await svc.whenCatalogReconciliationIdle(); + const registered = await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.get(session); + const listed = await svc.listSessions(); + + assert.deepStrictEqual({ + isRead: await db.getMetadata(AH_META_IS_READ_DB_KEY), + modifiedTime: registered?.modifiedTime, + summary: listed[0]?.summary, + }, { + isRead: '', + modifiedTime: rediscoveredModifiedTime, + summary: 'After rediscovery', + }); + }); + + test('a failed recency batch does not skip independent discovery post-processing', async () => { + const database = new TransientRegistryWriteDatabase(); + const svc = createExternalSessionService(createSessionDataService(), database); + const agent = disposables.add(new TimedExternalAgent('copilot')); + const existing = agent.addSession('existing-before-batch-failure', 10); + await database.registerRuntimeSession(existing.toString(), { + provider: 'copilot', + startTime: 10, + modifiedTime: 10, + source: 'restore', + }, { checkTombstone: false }); + const register = (svc as unknown as { + _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise; + })._registerDiscoveredChats.bind(svc); + const added = agent.addSession('added-during-batch-failure', 20); + database.failModifiedTimeBatches(2); + + const changed = await register(agent, [ + discoveredChat(existing, false, 30), + discoveredChat(added, false, 20), + ]); + const registered = new Set((await svc.getRegisteredSessions()).map(session => session.toString())); + + assert.deepStrictEqual({ + changed, + modifiedTimeBatchAttempts: database.modifiedTimeBatchAttempts, + addedWasRegistered: registered.has(added.toString()), + }, { + changed: true, + modifiedTimeBatchAttempts: 2, + addedWasRegistered: true, + }); + }); + + testWithExternalSessionClock('discovery does not ingest external sessions older than 30 days', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); const svc = createExternalSessionService(); @@ -3614,7 +4890,9 @@ suite('AgentService (node dispatcher)', () => { test('defers titling the two most recently updated untitled external sessions until startup settled', async () => { const now = Date.now(); const copilotApiService = new TestCopilotApiService(); - const svc = createExternalSessionService(createPerSessionDataService().service, undefined, copilotApiService); + const perSession = createPerSessionDataService(); + const orchestratorDatabase = new TransientRegistryWriteDatabase(); + const svc = createExternalSessionService(perSession.service, orchestratorDatabase, copilotApiService); const agent = disposables.add(new TimedExternalAgent('copilot')); const oldest = agent.addSession('oldest', now - 3000); const middle = agent.addSession('middle', now - 2000); @@ -3643,18 +4921,85 @@ suite('AgentService (node dispatcher)', () => { svc.markStartupComplete(); // The lane is serialized, so settling implies generation finished: no polling. await svc.whenDeferredWorkSettled(); + await svc.whenCatalogReconciliationIdle(); const titled = [oldest, middle, newest].filter(session => copilotApiService.utilityCalls.some( call => call.request.messages.some(message => message.content.includes(`prompt of ${buildDefaultChatUri(session)}`)))); + const persistedTitles = Object.fromEntries(await Promise.all([middle, newest].map(async session => [ + AgentSession.id(session), + catalogDataOf(await orchestratorDatabase.getSessionV2(session.toString()))?.summary, + ]))); + const restarted = createExternalSessionService(perSession.service, orchestratorDatabase); + setExternalSessionsMode(restarted, AgentHostExternalSessionsMode.Last30Days, 1); + await waitForSessionListReconciliation(restarted); + const titlesAfterRestart = Object.fromEntries((await restarted.listSessions()) + .filter(metadata => metadata.session.toString() === middle.toString() || metadata.session.toString() === newest.toString()) + .map(metadata => [AgentSession.id(metadata.session), metadata.summary])); assert.deepStrictEqual({ callsBeforeStartupSettled, callsAfterSettled: copilotApiService.utilityCalls.length, titled: titled.map(session => AgentSession.id(session)), + persistedTitles, + titlesAfterRestart, }, { callsBeforeStartupSettled: 0, callsAfterSettled: 2, titled: ['middle', 'newest'], + persistedTitles: { + middle: 'Generated session title', + newest: 'Generated session title', + }, + titlesAfterRestart: { + middle: 'Generated session title', + newest: 'Generated session title', + }, + }); + }); + + test('external activity during reconciliation queues one trailing pass', async () => { + const svc = createExternalSessionService(); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: 'copilot' }); + getStateManager(svc).setSessionMeta(session.toString(), withSessionExternal(undefined, true)); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); + await waitForSessionListReconciliation(svc); + + const firstPassStarted = new DeferredPromise(); + const releaseFirstPass = new DeferredPromise(); + let passes = 0; + const reconciliationTarget = svc as unknown as { + _reconcileExternalSessions(previousMode: AgentHostExternalSessionsMode | undefined, forceCatalogRefresh: boolean): Promise; + _queueSessionListReconciliation(): void; + }; + reconciliationTarget._reconcileExternalSessions = async () => { + passes++; + if (passes === 1) { + firstPassStarted.complete(); + await releaseFirstPass.p; + } + }; + + reconciliationTarget._queueSessionListReconciliation(); + await firstPassStarted.p; + const modifiedAt = new Date(Date.now() + 1000).toISOString(); + const summaryChanged = Event.toPromise(Event.filter( + getStateManager(svc).onDidChangeSessionSummary, + event => event.session === session.toString() && event.changes.modifiedAt === modifiedAt, + )); + getStateManager(svc).dispatchServerAction(buildDefaultChatUri(session), { + type: ActionType.ChatTurnStarted, + turnId: 'overlap-turn', + startedAt: modifiedAt, + message: { text: 'activity during reconciliation', origin: { kind: MessageKind.User } }, }); + await summaryChanged; + releaseFirstPass.complete(); + for (let attempt = 0; attempt < 50 && passes < 2; attempt++) { + await timeout(0); + } + + assert.strictEqual(passes, 2); }); testWithExternalSessionClock('prune removes stale external sessions but keeps adoptable-legacy sessions', async () => { @@ -3830,7 +5175,9 @@ suite('AgentService (node dispatcher)', () => { testWithExternalSessionClock('filters external sessions in every mode', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); - const svc = createExternalSessionService(); + // Per-session databases: the catalog relay snapshot is per session, so a + // shared test database would let one session's pending payload land on another. + const svc = createExternalSessionService(createPerSessionDataService().service); const agent = disposables.add(new TimedExternalAgent('copilot')); agent.addSession('recent', now); agent.addSession('within-24-hours', now - day + day / 2); @@ -3868,7 +5215,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - testWithExternalSessionClock('a mode that hides every external session skips the catalog work for them', async () => { + testWithExternalSessionClock('clean catalog rows avoid session DB opens in every visibility mode', async () => { const now = Date.now(); const perSession = createPerSessionDataService(); const svc = createExternalSessionService(perSession.service); @@ -3877,9 +5224,8 @@ suite('AgentService (node dispatcher)', () => { agent.addSession('external-two', now); registerTestAgentProvider(svc, agent); await svc.listSessions(AgentHostExternalSessionsMode.Last30Days); + await svc.whenCatalogReconciliationIdle(); - // A catalog pass otherwise opens every registered session's database, - // so a mode that discards the row regardless must not pay for it. const opened: string[] = []; const dataService = perSession.service as { tryOpenDatabase(session: URI): Promise }; const originalTryOpen = dataService.tryOpenDatabase; @@ -3897,13 +5243,43 @@ suite('AgentService (node dispatcher)', () => { hidden: [], openedWhileHidden: [], visible: ['external-one', 'external-two'], - openedWhileVisible: ['external-one', 'external-two'], + openedWhileVisible: [], }); } finally { dataService.tryOpenDatabase = originalTryOpen; } }); + testWithExternalSessionClock('external visibility reconciliation continues when cache verification fails', async () => { + class FailingDirtyMarkerDatabase extends TransientRegistryWriteDatabase { + failNextDirtySweep = false; + + override async markAllSessionsV2PayloadsDirty(): Promise { + if (this.failNextDirtySweep) { + this.failNextDirtySweep = false; + throw new Error('dirty marker unavailable'); + } + return super.markAllSessionsV2PayloadsDirty(); + } + } + + const database = new FailingDirtyMarkerDatabase(); + const svc = createExternalSessionService(createPerSessionDataService().service, database); + const agent = disposables.add(new TimedExternalAgent('copilot')); + agent.addSession('external', Date.now()); + registerTestAgentProvider(svc, agent); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); + await waitForSessionListReconciliation(svc); + await svc.whenCatalogReconciliationIdle(); + + database.failNextDirtySweep = true; + await (svc as unknown as { + _reconcileExternalSessions(previousMode: AgentHostExternalSessionsMode | undefined, forceCatalogRefresh: boolean): Promise; + })._reconcileExternalSessions(undefined, true); + + assert.deepStrictEqual((await svc.listSessions()).map(session => AgentSession.id(session.session)), ['external']); + }); + testWithExternalSessionClock('a mode change reconciles with a single catalog pass', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); @@ -4021,11 +5397,11 @@ suite('AgentService (node dispatcher)', () => { const second = AgentSession.uri('copilot', 'second'); const third = AgentSession.uri('copilot', 'third'); for (const [session, startTime] of [[first, now - 1], [second, now - 2], [third, now - 3]] as const) { - await database.registerSession(session.toString(), { provider: 'copilot', startTime, source: 'discovery' }, { checkTombstone: true }); + await database.registerSessionV2(session.toString(), { provider: 'copilot', startTime, source: 'discovery' }, { checkTombstone: true }); } await database.markProviderBackfilled('copilot'); - const svc = createExternalSessionService(createSessionDataService(), database); + const svc = createExternalSessionService(createPerSessionDataService().service, database); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); @@ -4035,7 +5411,6 @@ suite('AgentService (node dispatcher)', () => { registerTestAgentProvider(svc, agent); const initiallyListed = await svc.listSessions(); - exposeListedSessions(svc, initiallyListed); const notifications: string[] = []; disposables.add(svc.onDidNotification(notification => { if (notification.type === NotificationType.SessionAdded) { @@ -4044,12 +5419,17 @@ suite('AgentService (node dispatcher)', () => { notifications.push(`remove:${AgentSession.id(URI.parse(notification.session))}`); } })); + exposeListedSessions(svc, initiallyListed); + for (let i = 0; i < 20 && notifications.length < initiallyListed.length; i++) { + await timeout(0); + } + notifications.length = 0; agent.addSession('third', now); - (svc as unknown as { _queueSessionListReconciliation(): void })._queueSessionListReconciliation(); + (svc as unknown as { _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode, forceCatalogRefresh?: boolean): void })._queueSessionListReconciliation(undefined, true); await waitForSessionListReconciliation(svc); agent.addSession('second', now + 1); - (svc as unknown as { _queueSessionListReconciliation(): void })._queueSessionListReconciliation(); + (svc as unknown as { _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode, forceCatalogRefresh?: boolean): void })._queueSessionListReconciliation(undefined, true); await waitForSessionListReconciliation(svc); assert.deepStrictEqual({ @@ -4059,7 +5439,7 @@ suite('AgentService (node dispatcher)', () => { }, { initiallyListed: ['first', 'second'], visible: ['second', 'third'], - notifications: ['add:first', 'add:third', 'remove:second', 'add:second', 'remove:first'], + notifications: ['add:third', 'remove:second', 'add:second', 'remove:first'], }); }); @@ -4115,45 +5495,36 @@ suite('AgentService (node dispatcher)', () => { }); }); - testWithExternalSessionClock('recent reconciles clients when a hidden external session becomes more recent', async () => { + testWithExternalSessionClock('recent listing selects a restored external session after it becomes more recent', async () => { const now = Date.now(); const svc = createExternalSessionService(); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); const first = agent.addSession('first', now - 1); - const second = agent.addSession('second', now - 2); + agent.addSession('second', now - 2); const third = agent.addSession('third', now - 3); registerTestAgentProvider(svc, agent); await svc.listSessions(); await waitForSessionListReconciliation(svc); - await svc.restoreSession(third); - const notifications: string[] = []; - disposables.add(svc.onDidNotification(notification => { - if (notification.type === NotificationType.SessionAdded) { - notifications.push(`add:${AgentSession.id(URI.parse(notification.summary.resource))}`); - } else if (notification.type === NotificationType.SessionRemoved) { - notifications.push(`remove:${AgentSession.id(URI.parse(notification.session))}`); - } - })); + await svc.restoreSession(third); + await waitForSessionListReconciliation(svc); + await timeout(1_000); getStateManager(svc).dispatchServerAction(buildDefaultChatUri(third), { type: ActionType.ChatTurnStarted, turnId: 'turn-third', - startedAt: new Date(now).toISOString(), + startedAt: new Date().toISOString(), message: { text: 'Update', origin: { kind: MessageKind.User } }, }); await timeout(150); await waitForSessionListReconciliation(svc); - assert.deepStrictEqual({ - visible: (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(), - notifications, - }, { - visible: [AgentSession.id(first), AgentSession.id(third)].sort(), - notifications: ['add:third', `remove:${AgentSession.id(second)}`], - }); + assert.deepStrictEqual( + (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(), + [AgentSession.id(first), AgentSession.id(third)].sort(), + ); }); testWithExternalSessionClock('configuration changes add and remove non-live external sessions immediately', async () => { @@ -4408,6 +5779,64 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('invalidation during a self-refresh queues a trailing computation', async () => { + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + await svc.createSession({ provider: 'copilot' }); + await waitForSessionListReconciliation(svc); + + const firstReadStarted = new DeferredPromise(); + const releaseFirstRead = new DeferredPromise(); + const refreshReadStarted = new DeferredPromise(); + const releaseRefreshRead = new DeferredPromise(); + const inner = svc as unknown as { + _computeSessions(mode: AgentHostExternalSessionsMode, epoch?: number): Promise; + _listRegisteredSessions(): Promise; + _invalidateSessionList(): void; + }; + const originalCompute = inner._computeSessions; + const originalListRegistered = inner._listRegisteredSessions; + let computations = 0; + let registryReads = 0; + inner._computeSessions = async (mode, epoch) => { + computations++; + return originalCompute.call(svc, mode, epoch); + }; + inner._listRegisteredSessions = async () => { + const registered = await originalListRegistered.call(svc); + registryReads++; + if (registryReads === 1) { + firstReadStarted.complete(); + await releaseFirstRead.p; + } else if (registryReads === 3) { + refreshReadStarted.complete(); + await releaseRefreshRead.p; + } + return registered; + }; + + const beforeMutation = svc.listSessions(); + await firstReadStarted.p; + await svc.createSession({ provider: 'copilot' }); + releaseFirstRead.complete(); + await refreshReadStarted.p; + inner._invalidateSessionList(); + const afterRefreshInvalidation = svc.listSessions(); + releaseRefreshRead.complete(); + const [beforeMutationResult, afterRefreshInvalidationResult] = await Promise.all([beforeMutation, afterRefreshInvalidation]); + + assert.deepStrictEqual({ + computations, + beforeMutation: beforeMutationResult.length, + afterRefreshInvalidation: afterRefreshInvalidationResult.length, + }, { + computations: 3, + beforeMutation: 2, + afterRefreshInvalidation: 2, + }); + }); + test('provider registration queues a trailing list computation without overlap', async () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const gate = new DeferredPromise(); @@ -4444,36 +5873,1823 @@ suite('AgentService (node dispatcher)', () => { ); }); - test('legacy migration and external discovery use separate provider catalogs and signals', async () => { - class SeparateCatalogAgent extends MockAgent { - private readonly _onDidDiscoverChats = new Emitter(); - override readonly onDidDiscoverChats = this._onDidDiscoverChats.event; - externalCalls = 0; - legacyCalls = 0; + suite('sessions_v2 direct importer', () => { + class DirectImportAgent extends MockAgent { + catalog: readonly IAgentChatMetadata[] | undefined = []; + catalogCalls = 0; + metadataCalls = 0; + adoptionCalls = 0; - override async listExternalChats(): Promise { - this.externalCalls++; - return [{ chat: URI.parse(buildDefaultChatUri(external)), startTime: Date.now(), modifiedTime: Date.now() }]; + override async listChatsToMigrate(): Promise { + this.catalogCalls++; + return this.catalog; } - override async listChatsToMigrate(): Promise { - this.legacyCalls++; - return [{ chat: URI.parse(buildDefaultChatUri(legacy)), startTime: Date.now(), modifiedTime: Date.now() }]; + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + this.metadataCalls++; + return super.getChatMetadata(chat, context); } - override fireDiscoveredChats(chats: readonly IAgentDiscoveredChat[]): void { this._onDidDiscoverChats.fire(chats); } - - override dispose(): void { - this._onDidDiscoverChats.dispose(); - super.dispose(); + async ensureChatAdopted(): Promise { + this.adoptionCalls++; + return { adopted: true, eligible: true }; } } - const external = AgentSession.uri('copilot', 'external-catalog'); - const legacy = AgentSession.uri('copilot', 'legacy-catalog'); - const sessionData = createPerSessionDataService(); - await sessionData.database(legacy).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'false'); - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + function metadata(session: URI, meta?: IAgentSessionMetadata['_meta']): IAgentChatMetadata { + return { + chat: URI.parse(buildDefaultChatUri(session)), + startTime: 1, + modifiedTime: Date.now(), + summary: AgentSession.id(session), + _meta: meta, + }; + } + + function createService(database: IAgentHostDatabase, sessionData: ISessionDataService): AgentService { + return disposables.add(createTestAgentService( + new NullLogService(), + fileService, + sessionData, + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + database, + )); + } + + test('does not lose progressively imported legacy sessions across open, Back, completion, and restart', async () => { + class GatedImportDatabase extends TransientRegistryWriteDatabase { + readonly importBlocked = new DeferredPromise(); + readonly releaseImport = new DeferredPromise(); + + override async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { + if (AgentSession.id(URI.parse(envelope.session)) >= 'legacy-050' && !this.releaseImport.isSettled) { + this.importBlocked.complete(); + await this.releaseImport.p; + } + return super.upsertSessionV2(envelope, expectedSessionGeneration); + } + } + + class LegacyOwnershipAgent extends DirectImportAgent { + readonly enumerationGate = new DeferredPromise(); + readonly legacyOwned: Set; + readonly adopted = new Set(); + + constructor(readonly sessions: readonly URI[], legacyOwned?: Set, adopted?: Set) { + super('copilot'); + this.legacyOwned = legacyOwned ?? new Set(sessions.map(session => session.toString())); + if (adopted) { + this.adopted = adopted; + } + } + + override async listChatsToMigrate(): Promise { + this.catalogCalls++; + await this.enumerationGate.p; + return this.sessions + .filter(session => this.legacyOwned.has(session.toString())) + .map(session => this.chatMetadata(session)); + } + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + this.metadataCalls++; + return this.chatMetadata(resolveAgentChatContext(context, chat).configurationResource); + } + + override async getSessionMetadata(session: URI): Promise { + const metadata = this.chatMetadata(session); + return { ...metadata, session }; + } + + override async ensureChatAdopted(chat?: URI, context?: URI | IAgentChatContext): Promise { + this.adoptionCalls++; + if (!chat || !context) { + throw new Error('Expected chat adoption context'); + } + const session = resolveAgentChatContext(context, chat).configurationResource; + const key = session.toString(); + this.adopted.add(key); + this.legacyOwned.delete(key); + return { adopted: true, eligible: true, listVisible: { title: `Legacy ${AgentSession.id(session)}`, titleSource: 'auto', isRead: true } }; + } + + private chatMetadata(session: URI): IAgentChatMetadata { + const key = session.toString(); + return { + chat: URI.parse(buildDefaultChatUri(session)), + startTime: 1, + modifiedTime: 1, + summary: `Legacy ${AgentSession.id(session)}`, + ...(!this.adopted.has(key) ? { _meta: withSessionEhcliAdoptable(undefined) } : {}), + }; + } + } + + const count = 101; + const sessions = Array.from({ length: count }, (_, index) => AgentSession.uri('copilot', `legacy-${index.toString().padStart(3, '0')}`)); + const expectedIds = sessions.map(session => session.toString()).sort(); + const database = new GatedImportDatabase(); + const perSession = createPerSessionDataService(); + const agent = disposables.add(new LegacyOwnershipAgent(sessions)); + const openDatabase = perSession.service.openDatabase; + const sessionDataService: ISessionDataService = { + ...perSession.service, + openDatabase: session => { + // The real extension stops listing an ID as soon as catalog + // synchronization creates its agentSessionData directory. + agent.legacyOwned.delete(session.toString()); + return openDatabase(session); + }, + }; + for (const session of sessions) { + await database.registerRuntimeSession(session.toString(), { + provider: 'copilot', + startTime: 1, + modifiedTime: 1, + source: 'restore', + }, { checkTombstone: false }); + } + const svc = createService(database, sessionDataService); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + registerTestAgentProvider(svc, agent); + + const phase = async (name: string, listed?: readonly IAgentSessionMetadata[]) => { + const hostIds = (listed ?? await svc.listSessions()).map(session => session.session.toString()).sort(); + const legacyIds = [...agent.legacyOwned].sort(); + const combinedIds = new Set([...legacyIds, ...hostIds]); + return { + name, + hostCount: hostIds.length, + legacyProviderCount: legacyIds.length, + legacyUiCacheCount: legacyIds.slice(0, 100).length, + combinedProviderCount: combinedIds.size, + absentCount: expectedIds.filter(id => !combinedIds.has(id)).length, + }; + }; + + // The first host list is served from the existing registry while provider + // enumeration is gated. The separate workbench provider can show at most + // its first 100 legacy rows at once. + const firstHostList = await svc.listSessions(); + const observations = [await phase('initial list', firstHostList)]; + + agent.enumerationGate.complete(); + await database.importBlocked.p; + assert.strictEqual((await database.listSessionV2Registrations()).length >= 50, true); + observations.push(await phase('migration paused after ~50')); + + // Opening adopts one row that catalog synchronization has already made + // the extension provider retract. + const opened = sessions[0]; + await svc.restoreSession(opened); + const backHostList = await svc.listSessions(); + observations.push(await phase('Back after opening one', backHostList)); + + database.releaseImport.complete(); + await waitForInitialProviderMigration(svc, agent); + const completedHostList = await svc.listSessions(); + observations.push(await phase('migration complete', completedHostList)); + assert.deepStrictEqual({ + currentIds: (await database.listSessionV2Registrations()).map(row => row.session).sort(), + catalogIds: (await database.listSessionsV2()).map(row => row.session).sort(), + }, { + currentIds: expectedIds, + catalogIds: expectedIds, + }); + + svc.dispose(); + const restarted = createService(database, perSession.service); + getConfigurationService(restarted).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + const restartedAgent = disposables.add(new LegacyOwnershipAgent(sessions, agent.legacyOwned, agent.adopted)); + restartedAgent.enumerationGate.complete(); + registerTestAgentProvider(restarted, restartedAgent); + const restartedHostIds = (await restarted.listSessions()).map(session => session.session.toString()).sort(); + const restartedLegacyIds = [...restartedAgent.legacyOwned].sort(); + const restartedCombinedIds = new Set([...restartedLegacyIds, ...restartedHostIds]); + observations.push({ + name: 'restart', + hostCount: restartedHostIds.length, + legacyProviderCount: restartedLegacyIds.length, + legacyUiCacheCount: restartedLegacyIds.slice(0, 100).length, + combinedProviderCount: restartedCombinedIds.size, + absentCount: expectedIds.filter(id => !restartedCombinedIds.has(id)).length, + }); + assert.deepStrictEqual({ + observations, + currentIds: (await database.listSessionV2Registrations()).map(row => row.session).sort(), + catalogIds: (await database.listSessionsV2()).map(row => row.session).sort(), + localDatabaseIds: [...perSession.databaseIds()].sort(), + }, { + observations: observations.map(observation => ({ ...observation, absentCount: 0 })), + currentIds: [...expectedIds], + catalogIds: [...expectedIds], + localDatabaseIds: [opened.toString()], + }); + }); + + test('preserves provider, generated, default, and custom titles through migration and restart', async () => { + class GatedTitleAgent extends DirectImportAgent { + readonly enumerationGate = new DeferredPromise(); + + override async listChatsToMigrate(): Promise { + this.catalogCalls++; + await this.enumerationGate.p; + return this.catalog ?? []; + } + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + this.metadataCalls++; + const session = resolveAgentChatContext(context, chat).configurationResource; + return this.catalog?.find(metadata => parseChatUri(metadata.chat)?.session === session.toString()); + } + + override async getSessionMetadata(session: URI): Promise { + const metadata = this.catalog?.find(metadata => parseChatUri(metadata.chat)?.session === session.toString()); + return metadata ? { ...metadata, session } : undefined; + } + } + + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const cases = [ + { id: 'provider-sdk-summary', providerTitle: 'Provider SDK Summary', expectedTitle: 'Provider SDK Summary', customTitle: undefined }, + { id: 'model-generated', providerTitle: 'Model Generated Title', expectedTitle: 'Model Generated Title', customTitle: undefined }, + { id: 'default-title', providerTitle: undefined, expectedTitle: undefined, customTitle: undefined }, + { id: 'explicit-custom-title', providerTitle: 'Provider Title Before Rename', expectedTitle: 'Explicit Custom Title', customTitle: 'Explicit Custom Title' }, + ] as const; + for (const entry of cases) { + const session = AgentSession.uri('copilot', entry.id); + await database.registerRuntimeSession(session.toString(), { + provider: 'copilot', + startTime: 1, + modifiedTime: 1, + source: 'restore', + }, { checkTombstone: false }); + if (entry.customTitle) { + await perSession.database(session).setMetadataValues({ + [SESSION_CUSTOM_TITLE_KEY]: entry.customTitle, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + }); + } + } + const expected = cases.map(entry => ({ + id: entry.id, + title: entry.expectedTitle, + })).sort((a, b) => a.id.localeCompare(b.id)); + const titles = (listed: readonly IAgentSessionMetadata[]) => listed.map(session => ({ + id: AgentSession.id(session.session), + title: session.summary, + })).sort((a, b) => a.id.localeCompare(b.id)); + const createAgent = () => { + const agent = disposables.add(new GatedTitleAgent('copilot')); + agent.catalog = cases.map(entry => ({ + ...metadata(AgentSession.uri('copilot', entry.id)), + summary: entry.providerTitle, + _meta: withSessionEhcliAdoptable(undefined), + })); + const mockSessions = (agent as unknown as { _sessions: Map })._sessions; + for (const entry of cases) { + mockSessions.set(entry.id, AgentSession.uri('copilot', entry.id)); + } + return agent; + }; + + const svc = createService(database, perSession.service); + const agent = createAgent(); + registerTestAgentProvider(svc, agent); + assert.deepStrictEqual(titles(await svc.listSessions()), []); + + agent.enumerationGate.complete(); + await waitForInitialProviderMigration(svc, agent); + assert.deepStrictEqual({ + titles: titles(await svc.listSessions()), + localDatabaseIds: [...perSession.databaseIds()].sort(), + catalog: (await database.listSessionsV2()).map(row => { + const data = catalogDataOf(row); + return { + id: AgentSession.id(row.session), + title: data?.summary, + ehcliAdoptable: readSessionEhcliAdoptable(data?._meta), + }; + }).sort((a, b) => a.id.localeCompare(b.id)), + }, { + titles: [], + localDatabaseIds: [AgentSession.uri('copilot', 'explicit-custom-title').toString()], + catalog: expected.map(entry => ({ ...entry, ehcliAdoptable: true })), + }); + + svc.dispose(); + const restarted = createService(database, perSession.service); + const restartedAgent = createAgent(); + restartedAgent.enumerationGate.complete(); + registerTestAgentProvider(restarted, restartedAgent); + assert.deepStrictEqual(titles(await restarted.listSessions()), []); + assert.deepStrictEqual({ + currentCount: (await database.listSessionV2Registrations()).length, + catalogCount: (await database.listSessionsV2()).length, + }, { + currentCount: cases.length, + catalogCount: cases.length, + }); + }); + + test('imports provider-only sessions directly without creating legacy rows', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + const providerOnly = AgentSession.uri('copilot', 'provider-only-v2'); + agent.catalog = [metadata(providerOnly)]; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + + assert.deepStrictEqual({ + legacy: await database.listSessions(), + currentRegistrations: (await database.listSessionV2Registrations()).map(row => row.session), + currentCatalog: (await database.listSessionsV2()).map(row => row.session), + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + oldGlobalMarker: await database.isSessionRegistryBackfilled(), + oldProviderMarker: await database.isProviderBackfilled('copilot'), + }, { + legacy: [], + currentRegistrations: [providerOnly.toString()], + currentCatalog: [providerOnly.toString()], + currentMarker: true, + oldGlobalMarker: false, + oldProviderMarker: false, + }); + }); + + test('fulfilled incomplete initial import retries provider-only candidates without another provider event', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + const providerOnly = AgentSession.uri('copilot', 'a-provider-only-retry'); + const existing = AgentSession.uri('copilot', 'z-existing-fallback'); + await database.registerRuntimeSession(existing.toString(), { + provider: 'copilot', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + agent.catalog = [metadata(providerOnly)]; + database.failRegistryWrites(1); + registerTestAgentProvider(svc, agent); + await (svc as unknown as { _initialProviderMigrations: Map> })._initialProviderMigrations.get(agent.id); + + await svc.listSessions(AgentHostExternalSessionsMode.Last30Days); + svc.markStartupComplete(); + await svc.whenDeferredWorkSettled(); + const listed = await svc.listSessions(AgentHostExternalSessionsMode.Last30Days); + + assert.deepStrictEqual({ + catalogCalls: agent.catalogCalls, + listed: listed.map(session => session.session.toString()), + current: await database.getSessionV2Registration(providerOnly.toString()), + marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + }, { + catalogCalls: 2, + listed: [providerOnly.toString()], + current: { + session: providerOnly.toString(), + provider: 'copilot', + startTime: 1, + modifiedTime: 1, + external: true, + source: 'discovery', + }, + marker: true, + }); + }); + + test('bounds oversized provider summaries and completes the provider migration marker', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + const session = AgentSession.uri('copilot', 'oversized-summary'); + const oversized = 'x'.repeat(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT + 100); + agent.catalog = [{ ...metadata(session), summary: oversized }]; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + const stored = await database.getSessionV2(session.toString()); + const decoded = stored && decodeAgentHostCatalogPayload(stored.payload); + + assert.deepStrictEqual({ + sourceSummaryLength: agent.catalog[0].summary?.length, + storedSummaryLength: decoded?.ok ? decoded.value.data.summary?.length : undefined, + storedSummaryEndsWithEllipsis: decoded?.ok ? decoded.value.data.summary?.endsWith('…') : false, + marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + }, { + sourceSummaryLength: AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT + 100, + storedSummaryLength: AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, + storedSummaryEndsWithEllipsis: true, + marker: true, + }); + }); + + test('runtime discovery after the current marker mirrors both registries', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + const discovered = AgentSession.uri('copilot', 'runtime-after-marker'); + const startTime = Date.now(); + + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats( + agent, + [discoveredChat(discovered, true, startTime)], + ); + + assert.deepStrictEqual({ + legacy: await database.getSession(discovered.toString()), + current: await database.getSessionV2Registration(discovered.toString()), + }, { + legacy: { session: discovered.toString(), provider: 'copilot', startTime, modifiedTime: startTime, external: true, source: 'discovery' }, + current: { session: discovered.toString(), provider: 'copilot', startTime, modifiedTime: startTime, external: true, source: 'discovery' }, + }); + }); + + test('discovery exclusion persistence failure does not suppress successful registrations', async () => { + class FailingExclusionDatabase extends TransientRegistryWriteDatabase { + async markSessionsV2ExcludedBatch(): Promise { + throw new Error('simulated exclusion write failure'); + } + } + const database = new FailingExclusionDatabase(); + const perSession = createPerSessionDataService(); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + const registered = AgentSession.uri('copilot', 'registered-despite-exclusion-failure'); + const stale = AgentSession.uri('copilot', 'stale-exclusion-write-failure'); + + const registeredModifiedTime = Date.now(); + const changed = await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [ + { ...metadata(registered), modifiedTime: registeredModifiedTime, external: false }, + { ...metadata(stale), modifiedTime: Date.now() - 46 * 24 * 60 * 60 * 1000, external: true }, + ]); + + assert.deepStrictEqual({ + changed, + registered: await database.getSessionV2Registration(registered.toString()), + staleExclusion: await database.getSessionsV2Exclusion('copilot', stale.toString()), + }, { + changed: true, + registered: { session: registered.toString(), provider: 'copilot', startTime: 1, modifiedTime: registeredModifiedTime, external: false, source: 'restore' }, + staleExclusion: undefined, + }); + }); + + test('discovery deduplicates legacy-only identities until the importer runs', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'legacy-only-discovery-dedup'); + await database.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + const writesBeforeDiscovery = database.registryWriteAttempts; + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + + const discoveredModifiedTime = Date.now(); + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [discoveredChat(session, true, discoveredModifiedTime)]); + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [discoveredChat(session, true, discoveredModifiedTime)]); + + assert.deepStrictEqual({ + legacy: await database.getSession(session.toString()), + current: await database.getSessionV2Registration(session.toString()), + // Recency advances are not registry identity writes, so the + // deduplicated identity is still never re-registered. + registryWriteAttempts: database.registryWriteAttempts, + }, { + legacy: { session: session.toString(), provider: 'copilot', startTime: 1, modifiedTime: discoveredModifiedTime, external: false, source: 'explicit' }, + current: undefined, + registryWriteAttempts: writesBeforeDiscovery, + }); + }); + + test('discovery racing import preserves effective legacy identity and importer remains idempotent', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'legacy-first-discovery-race'); + await database.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + database.listRuntimeCompatibleSessionKeys = async () => []; + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + let externalReconciliations = 0; + let externalTitleSchedules = 0; + (svc as unknown as { _queueSessionListReconciliation(): void })._queueSessionListReconciliation = () => { + externalReconciliations++; + }; + (svc as unknown as { _scheduleExternalSessionTitles(sessions: readonly IAgentSessionMetadata[]): void })._scheduleExternalSessionTitles = () => { + externalTitleSchedules++; + }; + + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [discoveredChat(session)]); + const revisionAfterDiscovery = (await database.getSessionV2(session.toString()))?.sourceRevision; + agent.catalog = [metadata(session)]; + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + assert.deepStrictEqual({ + legacy: await database.getSession(session.toString()), + current: await database.getSessionV2Registration(session.toString()), + catalogRows: (await database.listSessionsV2()).map(row => row.session), + revisions: [revisionAfterDiscovery, (await database.getSessionV2(session.toString()))?.sourceRevision], + externalReconciliations, + externalTitleSchedules, + }, { + legacy: { session: session.toString(), provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }, + current: { session: session.toString(), provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'explicit' }, + catalogRows: [session.toString()], + revisions: [0, 0], + externalReconciliations: 0, + externalTitleSchedules: 0, + }); + }); + + test('unions legacy rows and provider-only metadata directly into verified v2 rows', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const legacy = AgentSession.uri('copilot', 'legacy-union'); + const providerOnly = AgentSession.uri('copilot', 'provider-union'); + await database.registerSession(legacy.toString(), { provider: 'copilot', startTime: 2, source: 'restore' }, { checkTombstone: true }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [metadata(legacy), metadata(providerOnly)]; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + + assert.deepStrictEqual({ + legacy: (await database.listSessions()).map(row => row.session), + current: (await database.listSessionsV2()).map(row => row.session).sort(), + registrations: (await database.listSessionV2Registrations()).map(row => ({ + session: row.session, + source: row.source, + external: row.external, + })).sort((a, b) => a.session.localeCompare(b.session)), + }, { + legacy: [legacy.toString()], + current: [legacy.toString(), providerOnly.toString()].sort(), + registrations: [ + { session: legacy.toString(), source: 'restore', external: false }, + { session: providerOnly.toString(), source: 'discovery', external: true }, + ].sort((a, b) => a.session.localeCompare(b.session)), + }); + }); + + test('legacy-only external import preserves unread state', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'legacy-external-unread'); + await database.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + await perSession.database(session).setMetadata(AH_META_IS_READ_DB_KEY, ''); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [metadata(session)]; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + assert.deepStrictEqual({ + persistedRead: await perSession.database(session).getMetadata(AH_META_IS_READ_DB_KEY), + catalogRead: catalogDataOf(await database.getSessionV2(session.toString()))?.isRead, + }, { + persistedRead: '', + catalogRead: false, + }); + }); + + test('known external import without a local database preserves cached flags and titles', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'central-external-unread'); + await database.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 1, + source: 'discovery', + }, { checkTombstone: true }); + assert.strictEqual(await database.upsertSessionV2(catalogEnvelope(session, { + modifiedTime: 1, + summary: 'Cached external title', + titleSource: 'user', + isRead: false, + isArchived: true, + workingDirectories: [], + chats: [{ uri: buildDefaultChatUri(session), order: 0, kind: 'default', summary: 'Cached default title', titleSource: 'agent' }], + }, 'external-generation', 0), undefined), 'applied'); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [metadata(session)]; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, true); + + const data = catalogDataOf(await database.getSessionV2(session.toString())); + assert.deepStrictEqual({ + summary: data?.summary, + titleSource: data?.titleSource, + isRead: data?.isRead, + isArchived: data?.isArchived, + chats: data?.chats.map(chat => ({ summary: chat.summary, titleSource: chat.titleSource })), + localDatabaseIds: perSession.databaseIds(), + }, { + summary: 'Cached external title', + titleSource: 'user', + isRead: false, + isArchived: true, + chats: [{ summary: 'Cached default title', titleSource: 'agent' }], + localDatabaseIds: [], + }); + }); + + test('external import with a local database gives local flags and titles precedence', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'local-external-unread'); + await database.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 1, + source: 'discovery', + }, { checkTombstone: true }); + assert.strictEqual(await database.upsertSessionV2(catalogEnvelope(session, { + modifiedTime: 1, + summary: 'Stale read state', + titleSource: 'agent', + isRead: true, + isArchived: true, + workingDirectories: [], + _meta: { + workspaceless: true, + git: { branchName: 'stale-central' }, + }, + chats: [{ uri: buildDefaultChatUri(session), order: 0, kind: 'default', summary: 'Stale chat', titleSource: 'agent' }], + }, 'external-generation', 0), undefined), 'applied'); + await perSession.database(session).setMetadataValues({ + [AH_META_IS_READ_DB_KEY]: '', + [AH_META_IS_ARCHIVED_DB_KEY]: 'false', + [SESSION_CUSTOM_TITLE_KEY]: 'Local title', + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + [customChatTitleMetadataKey(buildDefaultChatUri(session))]: 'Local chat', + [customChatTitleSourceMetadataKey(buildDefaultChatUri(session))]: 'user', + }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [metadata(session, { + git: { branchName: 'current-provider' }, + })]; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, true); + + const data = catalogDataOf(await database.getSessionV2(session.toString())); + assert.deepStrictEqual({ + summary: data?.summary, + titleSource: data?.titleSource, + isRead: data?.isRead, + isArchived: data?.isArchived, + meta: data?._meta, + chats: data?.chats.map(chat => ({ summary: chat.summary, titleSource: chat.titleSource })), + }, { + summary: 'Local title', + titleSource: 'user', + isRead: false, + isArchived: false, + meta: { git: { branchName: 'current-provider' } }, + chats: [{ summary: 'Local chat', titleSource: 'user' }], + }); + }); + + test('full reconciliation without a local database preserves central host-owned status', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'central-status-reconciliation'); + const peer = buildChatUri(session, 'cached-peer'); + const centralMeta: NonNullable = { + multiRoot: { workspaceFile: 'file:///workspace/project.code-workspace' }, + 'vscode.folderPicker': { hidden: true, primary: 'file:///workspace' }, + github: { owner: 'microsoft', repo: 'vscode', pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'] }, + git: { hasGitHubRemote: true, branchName: 'feature/catalog', incomingChanges: 2 }, + 'vscode.sourceControl': { merge: { commit: '0123456789abcdef' }, latestOutcome: 'merge' }, + 'agentHost/sessionArtifacts': [{ id: 'artifact-1', type: 'pullRequest', label: 'Catalog payload', isArtifact: true, link: 'https://github.com/microsoft/vscode/pull/1' }], + 'agentHost/createdBySession': { session: 'agent-session://copilot/parent', chat: 'agent-chat://copilot/parent/default', turnId: 'turn-1' }, + workspaceless: true, + ehcliAdoptable: true, + ehcliAdopted: true, + [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: { version: 1, handle: '00000000-0000-4000-8000-000000000001' }, + }; + await database.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 1, + source: 'discovery', + }, { checkTombstone: true }); + assert.strictEqual(await database.upsertSessionV2(catalogEnvelope(session, { + modifiedTime: 1, + summary: 'Cached title', + titleSource: 'user', + isRead: true, + isArchived: true, + workingDirectories: [], + _meta: centralMeta, + chats: [ + { uri: buildDefaultChatUri(session), order: 0, kind: 'default', summary: 'Cached default title', titleSource: 'agent' }, + { uri: peer, order: 1, kind: 'peer', summary: 'Cached peer title', titleSource: 'user' }, + ], + }, 'external-generation', 0), undefined), 'applied'); + await database.markSessionV2PayloadDirty(session.toString()); + const svc = createService(database, perSession.service); + class ReconciliationAgent extends DirectImportAgent { + async listLegacyChatBackings(): Promise { + return [{ uri: URI.parse(peer), providerData: 'cached-peer-provider-data' }]; + } + } + const agent = disposables.add(new ReconciliationAgent('copilot')); + agent.sessionMetadataOverrides = { + modifiedTime: 2, + summary: 'Provider title', + status: SessionStatus.Idle, + project: { uri: URI.file('/provider/project'), displayName: 'Provider project' }, + workingDirectories: [URI.file('/provider/workspace')], + changes: { files: 3, additions: 4, deletions: 1 }, + _meta: { + git: { hasGitHubRemote: false, branchName: 'provider-refresh' }, + }, + }; + await createAgentSession(agent, { session }); + registerTestAgentProvider(svc, agent); + + const report = await (svc as unknown as { + _catalogReconciliationService: { runFullPass(): Promise<{ readonly outcomes: readonly { readonly status: string }[] }> }; + })._catalogReconciliationService.runFullPass(); + const data = catalogDataOf(await database.getSessionV2(session.toString())); + const { multiRoot: _multiRoot, ...centralMetaWithoutMultiRoot } = centralMeta; + + assert.deepStrictEqual({ + outcomes: report.outcomes.map(outcome => outcome.status), + summary: data?.summary, + isRead: data?.isRead, + isArchived: data?.isArchived, + project: data?.project, + workingDirectories: data?.workingDirectories, + changes: data?.changes, + meta: data?._meta, + chats: data?.chats.map(chat => ({ summary: chat.summary, titleSource: chat.titleSource })), + localDatabaseIds: perSession.databaseIds(), + }, { + outcomes: ['succeeded'], + summary: 'Cached title', + isRead: true, + isArchived: true, + project: { uri: URI.file('/provider/project').toString(), displayName: 'Provider project' }, + workingDirectories: [URI.file('/provider/workspace').toString()], + changes: { files: 3, additions: 4, deletions: 1 }, + meta: { + ...centralMetaWithoutMultiRoot, + git: { hasGitHubRemote: false, branchName: 'provider-refresh' }, + }, + chats: [ + { summary: 'Cached default title', titleSource: 'agent' }, + { summary: 'Cached peer title', titleSource: 'user' }, + ], + localDatabaseIds: [], + }); + }); + + test('no-local peer import prefers cached membership and only enriches matching provider data', async () => { + class CachedPeerAgent extends DirectImportAgent { + async listLegacyChatBackings(session: URI): Promise { + return [ + { uri: URI.parse(buildChatUri(session, 'cached')), providerData: 'matching-provider-data' }, + { uri: URI.parse(buildChatUri(session, 'provider-only')), providerData: 'must-not-expand-membership' }, + ]; + } + } + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'cached-peer-import'); + const cachedPeer = URI.parse(buildChatUri(session, 'cached')); + const origin = { kind: ChatOriginKind.User } as const; + await database.registerSessionV2(session.toString(), { + provider: 'copilot', + startTime: 1, + source: 'discovery', + }, { checkTombstone: true }); + assert.strictEqual(await database.upsertSessionV2(catalogEnvelope(session, { + modifiedTime: 1, + summary: 'Cached peers', + isRead: false, + isArchived: false, + workingDirectories: [], + chats: [ + { uri: buildDefaultChatUri(session), order: 0, kind: 'default' }, + { uri: cachedPeer.toString(), order: 1, kind: 'peer', origin, inheritedTurnId: 'inherited-turn' }, + ], + }, 'peer-generation', 0), undefined), 'applied'); + const svc = createService(database, perSession.service); + const agent = disposables.add(new CachedPeerAgent('copilot')); + + const peers = await (svc as unknown as { + _readOrImportPeerChatCatalogWithoutLocalDatabase(agent: IAgent, session: URI): Promise; + })._readOrImportPeerChatCatalogWithoutLocalDatabase(agent, session); + + assert.deepStrictEqual({ + peers, + persisted: await database.getSessionChatCatalog(session.toString()), + localDatabaseIds: perSession.databaseIds(), + }, { + peers: [{ uri: cachedPeer.toString(), providerData: 'matching-provider-data', origin, inheritedTurnId: 'inherited-turn' }], + persisted: { + revision: 1, + legacyMirroredRevision: 0, + legacyMirroredPayload: JSON.stringify([{ uri: cachedPeer.toString(), providerData: 'matching-provider-data', origin, inheritedTurnId: 'inherited-turn' }]), + chats: [{ + chat: cachedPeer.toString(), + order: 0, + providerData: 'matching-provider-data', + origin: JSON.stringify(origin), + inheritedTurnId: 'inherited-turn', + }], + }, + localDatabaseIds: [], + }); + + test('Copilot no-local cached peer import retries after provider backing enumeration recovers', async () => { + class RecoveringPeerAgent extends DirectImportAgent { + calls = 0; + async listLegacyChatBackings(session: URI): Promise { + this.calls++; + if (this.calls === 1) { + throw new Error('transient backing failure'); + } + return [{ uri: URI.parse(buildChatUri(session, 'cached')), providerData: 'recovered-provider-data' }]; + } + } + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'recovering-peer-import'); + const peer = buildChatUri(session, 'cached'); + await database.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + assert.strictEqual(await database.upsertSessionV2(catalogEnvelope(session, { + modifiedTime: 1, + isRead: false, + isArchived: false, + workingDirectories: [], + chats: [ + { uri: buildDefaultChatUri(session), order: 0, kind: 'default' }, + { uri: peer, order: 1, kind: 'peer' }, + ], + }, 'peer-generation', 0), undefined), 'applied'); + const svc = createService(database, perSession.service); + const agent = disposables.add(new RecoveringPeerAgent('copilot')); + const read = () => (svc as unknown as { + _readOrImportPeerChatCatalogWithoutLocalDatabase(agent: IAgent, session: URI): Promise; + })._readOrImportPeerChatCatalogWithoutLocalDatabase(agent, session); + + await assert.rejects(read(), /transient backing failure/); + const afterFailure = await database.getSessionChatCatalog(session.toString()); + const recovered = await read(); + + assert.deepStrictEqual({ + afterFailure, + recovered, + persisted: (await database.getSessionChatCatalog(session.toString()))?.chats, + localDatabaseIds: perSession.databaseIds(), + }, { + afterFailure: undefined, + recovered: [{ uri: peer, providerData: 'recovered-provider-data' }], + persisted: [{ chat: peer, order: 0, providerData: 'recovered-provider-data' }], + localDatabaseIds: [], + }); + }); + + test('Claude and Codex persist cached peers when enumeration is unavailable or lacks the URI', async () => { + class EnumeratingCodexAgent extends DirectImportAgent { + async listLegacyChatBackings(): Promise { + return []; + } + } + const results = []; + for (const provider of ['claude', 'codex'] as const) { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri(provider, 'optional-peer-data'); + const peer = buildChatUri(session, 'cached'); + await database.registerSessionV2(session.toString(), { provider, startTime: 1, source: 'discovery' }, { checkTombstone: true }); + assert.strictEqual(await database.upsertSessionV2(catalogEnvelope(session, { + modifiedTime: 1, + isRead: false, + isArchived: false, + workingDirectories: [], + chats: [ + { uri: buildDefaultChatUri(session), order: 0, kind: 'default' }, + { uri: peer, order: 1, kind: 'peer' }, + ], + }, `${provider}-generation`, 0), undefined), 'applied'); + const svc = createService(database, perSession.service); + const agent = disposables.add(provider === 'codex' ? new EnumeratingCodexAgent(provider) : new DirectImportAgent(provider)); + + const peers = await (svc as unknown as { + _readOrImportPeerChatCatalogWithoutLocalDatabase(agent: IAgent, session: URI): Promise; + })._readOrImportPeerChatCatalogWithoutLocalDatabase(agent, session); + results.push({ + provider, + peers, + persisted: (await database.getSessionChatCatalog(session.toString()))?.chats, + }); + } + + assert.deepStrictEqual(results, [ + { + provider: 'claude', + peers: [{ uri: buildChatUri(AgentSession.uri('claude', 'optional-peer-data'), 'cached') }], + persisted: [{ chat: buildChatUri(AgentSession.uri('claude', 'optional-peer-data'), 'cached'), order: 0 }], + }, + { + provider: 'codex', + peers: [{ uri: buildChatUri(AgentSession.uri('codex', 'optional-peer-data'), 'cached') }], + persisted: [{ chat: buildChatUri(AgentSession.uri('codex', 'optional-peer-data'), 'cached'), order: 0 }], + }, + ]); + }); + + test('transient peer backing enrichment failure does not block cached session restore', async () => { + class FailingBackingAgent extends DirectImportAgent { + async listLegacyChatBackings(): Promise { + throw new Error('transient backing failure'); + } + } + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'cached-peer-restore'); + const peer = buildChatUri(session, 'cached'); + await database.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: true }); + assert.strictEqual(await database.upsertSessionV2(catalogEnvelope(session, { + modifiedTime: 1, + summary: 'Cached session', + isRead: false, + isArchived: false, + workingDirectories: [], + chats: [ + { uri: buildDefaultChatUri(session), order: 0, kind: 'default' }, + { uri: peer, order: 1, kind: 'peer', summary: 'Cached peer', titleSource: 'user' }, + ], + }, 'restore-generation', 0), undefined), 'applied'); + const svc = createService(database, perSession.service); + const agent = disposables.add(new FailingBackingAgent('copilot')); + agent.catalog = [metadata(session, { summary: 'Cached session' })]; + registerTestAgentProvider(svc, agent); + + await svc.restoreSession(session); + const state = getStateManager(svc).getSessionState(session.toString()); + + assert.deepStrictEqual({ + peer: state?.chats.find(chat => chat.resource === peer), + authoritativeCatalog: await database.getSessionChatCatalog(session.toString()), + }, { + peer: { + resource: peer, + title: 'Cached peer', + status: SessionStatus.Idle, + modifiedAt: state?.chats.find(chat => chat.resource === peer)?.modifiedAt, + }, + authoritativeCatalog: undefined, + }); + }); + + test('authoritative empty cached membership accepts a later older-build addition', async () => { + class EmptyPeerAgent extends DirectImportAgent { + async listLegacyChatBackings(): Promise { + return []; + } + } + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'empty-peer-import'); + const added = buildChatUri(session, 'added'); + await database.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + assert.strictEqual(await database.upsertSessionV2(catalogEnvelope(session, { + modifiedTime: 1, + isRead: false, + isArchived: false, + workingDirectories: [], + chats: [{ uri: buildDefaultChatUri(session), order: 0, kind: 'default' }], + }, 'peer-generation', 0), undefined), 'applied'); + const svc = createService(database, perSession.service); + const agent = disposables.add(new EmptyPeerAgent('copilot')); + const withoutDatabase = await (svc as unknown as { + _readOrImportPeerChatCatalogWithoutLocalDatabase(agent: IAgent, session: URI): Promise; + })._readOrImportPeerChatCatalogWithoutLocalDatabase(agent, session); + await perSession.database(session).setMetadata('peerChats', JSON.stringify([{ uri: added, providerData: 'added-provider-data' }])); + const withDatabase = await (svc as unknown as { + _readOrMigrateLegacyPeerChatCatalog(agent: IAgent, session: URI, database: IReference): Promise; + })._readOrMigrateLegacyPeerChatCatalog(agent, session, { object: perSession.database(session), dispose: () => { } }); + + assert.deepStrictEqual({ + withoutDatabase, + withDatabase, + central: await database.getSessionChatCatalog(session.toString()), + legacy: await perSession.database(session).getMetadata('peerChats'), + }, { + withoutDatabase: [], + withDatabase: [{ uri: added, providerData: 'added-provider-data' }], + central: { + revision: 2, + legacyMirroredRevision: 2, + legacyMirroredPayload: JSON.stringify([{ uri: added, providerData: 'added-provider-data' }]), + chats: [{ chat: added, order: 0, providerData: 'added-provider-data' }], + }, + legacy: JSON.stringify([{ uri: added, providerData: 'added-provider-data' }]), + }); + }); + }); + + test('resolves legacy NULL provenance before its single v2 registration', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const legacy = AgentSession.uri('copilot', 'legacy-null-provenance'); + database.addLegacySessionWithoutExternal({ + session: legacy.toString(), + provider: 'copilot', + startTime: 2, + modifiedTime: 2, + external: false, + source: 'explicit', + }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [metadata(legacy)]; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + + const registration = await database.getSessionV2Registration(legacy.toString()); + const projection = await database.getSessionV2(legacy.toString()); + assert.deepStrictEqual({ + registration: registration && { external: registration.external, source: registration.source }, + projection: projection && { external: projection.external, source: projection.source }, + }, { + registration: { external: true, source: 'discovery' }, + projection: { external: true, source: 'discovery' }, + }); + }); + + test('resumes partial current migration and is idempotent', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const verified = AgentSession.uri('copilot', 'already-verified'); + const incomplete = AgentSession.uri('copilot', 'incomplete-current'); + const missing = AgentSession.uri('copilot', 'missing-current'); + await seedVerifiedSessionV2(database, perSession.database(verified), verified, true); + await database.registerSessionV2(incomplete.toString(), { provider: 'copilot', startTime: 2, source: 'restore' }, { checkTombstone: true }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [metadata(verified), metadata(incomplete), metadata(missing)]; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + const missingRevisionAfterInitialImport = (await database.getSessionV2(missing.toString()))?.sourceRevision; + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, true); + + assert.deepStrictEqual({ + registrations: (await database.listSessionV2Registrations()).map(row => row.session).sort(), + catalog: (await database.listSessionsV2()).map(row => row.session).sort(), + verifiedGeneration: (await database.getSessionV2(verified.toString()))?.sessionGeneration, + missingRevisions: [missingRevisionAfterInitialImport, (await database.getSessionV2(missing.toString()))?.sourceRevision], + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + catalogCalls: agent.catalogCalls, + }, { + registrations: [verified.toString(), incomplete.toString(), missing.toString()].sort(), + catalog: [verified.toString(), incomplete.toString(), missing.toString()].sort(), + verifiedGeneration: 'verified-generation', + missingRevisions: [undefined, 0], + currentMarker: true, + catalogCalls: 2, + }); + }); + + test('all-terminal repeated import keeps a stable revision without reconciliation writes', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + const session = AgentSession.uri('copilot', 'stable-import-revision'); + const stableMetadata = metadata(session); + agent.catalog = [stableMetadata]; + let reconciliationSchedules = 0; + (svc as unknown as { _catalogReconciliationService: { schedule(): void } })._catalogReconciliationService.schedule = () => { + reconciliationSchedules++; + }; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + const firstRevision = (await database.getSessionV2(session.toString()))?.sourceRevision; + const firstUpsertAttempts = database.sessionV2UpsertAttempts; + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, true); + + assert.deepStrictEqual({ + revisions: [firstRevision, (await database.getSessionV2(session.toString()))?.sourceRevision], + upsertAttempts: [firstUpsertAttempts, database.sessionV2UpsertAttempts], + reconciliationSchedules, + }, { + revisions: [0, 0], + upsertAttempts: [1, 1], + reconciliationSchedules: 0, + }); + }); + + test('reconciles old to new to intermediate to new cycles without duplicates', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + const original = AgentSession.uri('copilot', 'cycle-original'); + const intermediate = AgentSession.uri('copilot', 'cycle-intermediate'); + agent.catalog = [metadata(original)]; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + const originalGeneration = (await database.getSessionV2(original.toString()))?.sessionGeneration; + + // Simulate an intermediate build running its own migration and then + // creating another session in the legacy registry. + await database.registerSession(original.toString(), { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.registerSession(intermediate.toString(), { provider: 'copilot', startTime: 2, source: 'restore' }, { checkTombstone: false }); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(intermediate), intermediate); + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + assert.deepStrictEqual({ + legacy: (await database.listSessions()).map(row => ({ session: row.session, source: row.source })).sort((a, b) => a.session.localeCompare(b.session)), + current: (await database.listSessionV2Registrations()).map(row => ({ session: row.session, source: row.source })).sort((a, b) => a.session.localeCompare(b.session)), + catalog: (await database.listSessionsV2()).map(row => row.session).sort(), + originalGenerationStable: (await database.getSessionV2(original.toString()))?.sessionGeneration === originalGeneration, + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + }, { + legacy: [ + { session: intermediate.toString(), source: 'restore' }, + { session: original.toString(), source: 'explicit' }, + ].sort((a, b) => a.session.localeCompare(b.session)), + current: [ + { session: intermediate.toString(), source: 'restore' }, + { session: original.toString(), source: 'explicit' }, + ].sort((a, b) => a.session.localeCompare(b.session)), + catalog: [intermediate.toString(), original.toString()].sort(), + originalGenerationStable: true, + currentMarker: true, + }); + }); + + test('current marker skips complete rows without enumeration or session DB opens but imports later legacy rows', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + await database.markSessionRegistryBackfilled(); + await database.markProviderBackfilled('copilot'); + const imported = AgentSession.uri('copilot', 'old-markers-do-not-gate'); + const first = createService(database, perSession.service); + const firstAgent = disposables.add(new DirectImportAgent('copilot')); + firstAgent.catalog = [metadata(imported)]; + registerTestAgentProvider(first, firstAgent); + await first.listSessions(); + + const second = createService(database, perSession.service); + const secondAgent = disposables.add(new DirectImportAgent('copilot')); + perSession.databaseOpens.length = 0; + await (second as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(secondAgent, false); + const completePassOpens = [...perSession.databaseOpens]; + const intermediateLegacy = AgentSession.uri('copilot', 'intermediate-legacy-after-marker'); + await database.registerSession(intermediateLegacy.toString(), { provider: 'copilot', startTime: 2, source: 'restore' }, { checkTombstone: true }); + (secondAgent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(intermediateLegacy), intermediateLegacy); + secondAgent.catalog = undefined; + await (second as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(secondAgent, false); + + assert.deepStrictEqual({ + imported: (await database.listSessionsV2()).map(row => row.session).sort(), + firstCatalogCalls: firstAgent.catalogCalls, + secondCatalogCalls: secondAgent.catalogCalls, + completePassOpens, + completeSessionOpens: perSession.databaseOpens.filter(session => session === imported.toString()), + oldGlobalMarker: await database.isSessionRegistryBackfilled(), + oldProviderMarker: await database.isProviderBackfilled('copilot'), + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + }, { + imported: [imported.toString(), intermediateLegacy.toString()].sort(), + firstCatalogCalls: 1, + secondCatalogCalls: 0, + completePassOpens: [], + completeSessionOpens: [], + oldGlobalMarker: true, + oldProviderMarker: true, + currentMarker: true, + }); + }); + + test('marker-fast pass reconciles newer legacy provenance without changing the catalog revision', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'intermediate-provenance-update'); + await seedVerifiedSessionV2(database, perSession.database(session), session, false); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + await database.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = undefined; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + const current = await database.getSessionV2(session.toString()); + assert.deepStrictEqual({ + identity: current && { + provider: current.provider, + startTime: current.startTime, + modifiedTime: current.modifiedTime, + external: current.external, + source: current.source, + }, + sourceRevision: current?.sourceRevision, + upsertAttempts: database.sessionV2UpsertAttempts, + catalogCalls: agent.catalogCalls, + }, { + identity: { provider: 'copilot', startTime: 1, modifiedTime: 1, external: true, source: 'discovery' }, + sourceRevision: 0, + upsertAttempts: 1, + catalogCalls: 0, + }); + }); + + test('marker-fast pass merges downgrade recency and restores Recent visibility without provider discovery', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'downgrade-recency'); + const recentModifiedTime = Date.now(); + await seedVerifiedSessionV2(database, perSession.database(session), session, true); + await database.registerSession(session.toString(), { + provider: 'copilot', + startTime: 1, + modifiedTime: recentModifiedTime, + source: 'discovery', + }, { checkTombstone: true }); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = undefined; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + const listed = await svc.listSessions(AgentHostExternalSessionsMode.Last30Days); + + assert.deepStrictEqual({ + catalogCalls: agent.catalogCalls, + currentModifiedTime: (await database.getSessionV2Registration(session.toString()))?.modifiedTime, + listed: listed.map(item => ({ session: item.session.toString(), modifiedTime: item.modifiedTime })), + }, { + catalogCalls: 0, + currentModifiedTime: recentModifiedTime, + listed: [{ session: session.toString(), modifiedTime: recentModifiedTime }], + }); + }); + + test('marker-fast pass ignores unresolved legacy provenance across repeated starts', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'legacy-null-current-resolved'); + await seedVerifiedSessionV2(database, perSession.database(session), session, true); + database.addLegacySessionWithoutExternal({ + session: session.toString(), + provider: 'copilot', + startTime: 1, + modifiedTime: 1, + external: false, + source: 'explicit', + }); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = undefined; + perSession.databaseOpens.length = 0; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + const current = await database.getSessionV2(session.toString()); + assert.deepStrictEqual({ + current: current && { external: current.external, source: current.source, sourceRevision: current.sourceRevision }, + legacy: await database.getSession(session.toString()), + reconcileAttempts: database.sessionV2ReconcileAttempts, + upsertAttempts: database.sessionV2UpsertAttempts, + externalUpdates: database.externalUpdates, + catalogCalls: agent.catalogCalls, + databaseOpens: perSession.databaseOpens, + }, { + current: { external: true, source: 'discovery', sourceRevision: 0 }, + legacy: { session: session.toString(), provider: 'copilot', startTime: 1, modifiedTime: 1, external: undefined, source: 'explicit' }, + reconcileAttempts: 0, + upsertAttempts: 1, + externalUpdates: [], + catalogCalls: 0, + databaseOpens: [], + }); + }); + + test('marker-fast pass preserves a later explicit current incarnation with a matching receipt', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'later-current-incarnation'); + await seedVerifiedSessionV2(database, perSession.database(session), session, false); + await database.reconcileSessionV2RegistrationFromLegacy(session.toString(), { + session: session.toString(), + provider: 'copilot', + startTime: 5, + modifiedTime: 5, + external: false, + source: 'explicit', + }); + await database.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: true }); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = undefined; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + const current = await database.getSessionV2(session.toString()); + assert.deepStrictEqual({ + current: current && { startTime: current.startTime, modifiedTime: current.modifiedTime, external: current.external, source: current.source }, + legacy: await database.getSession(session.toString()), + sourceRevision: current?.sourceRevision, + upsertAttempts: database.sessionV2UpsertAttempts, + }, { + current: { startTime: 5, modifiedTime: 5, external: false, source: 'explicit' }, + legacy: { session: session.toString(), provider: 'copilot', startTime: 1, modifiedTime: 1, external: false, source: 'restore' }, + sourceRevision: 0, + upsertAttempts: 1, + }); + }); + + test('marker-fast pass keeps current rows when legacy is absent', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'current-without-legacy'); + await seedVerifiedSessionV2(database, perSession.database(session), session, false); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = undefined; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + assert.deepStrictEqual({ + legacy: await database.getSession(session.toString()), + current: (await database.listSessionV2Registrations()).map(row => row.session), + catalog: (await database.listSessionsV2()).map(row => row.session), + catalogCalls: agent.catalogCalls, + }, { + legacy: undefined, + current: [session.toString()], + catalog: [session.toString()], + catalogCalls: 0, + }); + }); + + test('intermediate explicit recreation after tombstone imports one new current incarnation', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'intermediate-recreate'); + await seedVerifiedSessionV2(database, perSession.database(session), session, false); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + await database.tombstoneAndUnregisterSession(session.toString()); + await database.registerSession(session.toString(), { provider: 'copilot', startTime: 2, source: 'explicit' }, { checkTombstone: false }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + agent.catalog = undefined; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + assert.deepStrictEqual({ + tombstoned: await database.isSessionTombstoned(session.toString()), + legacy: (await database.listSessions()).map(row => ({ session: row.session, startTime: row.startTime })), + current: (await database.listSessionV2Registrations()).map(row => ({ session: row.session, startTime: row.startTime })), + catalog: (await database.listSessionsV2()).map(row => row.session), + }, { + tombstoned: false, + legacy: [{ session: session.toString(), startTime: 2 }], + current: [{ session: session.toString(), startTime: 2 }], + catalog: [session.toString()], + }); + }); + + test('marker rerun upgrades an outdated payload without forcing an existing external row read', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'outdated-unread'); + await seedVerifiedSessionV2(database, perSession.database(session), session, true, false); + await perSession.database(session).setMetadata(AH_META_IS_READ_DB_KEY, ''); + const catalog = await database.getSessionV2(session.toString()); + assert.ok(catalog); + const outdatedGeneration = 'outdated-generation'; + await perSession.database(session).transitionMetadataValuesAndCatalogSyncSnapshot({}, catalog.sessionGeneration, { + sessionGeneration: outdatedGeneration, + sourceRevision: catalog.sourceRevision, + projectionVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION - 1, + payload: catalog.payload, + payloadHash: catalog.payloadHash, + state: 'pending', + }); + await perSession.database(session).acknowledgeCatalogSyncSnapshot({ + sessionGeneration: outdatedGeneration, + sourceRevision: catalog.sourceRevision, + projectionVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION - 1, + payloadHash: catalog.payloadHash, + }); + database.setSessionV2PayloadReceipt(session, AGENT_HOST_CATALOG_PAYLOAD_VERSION - 1, outdatedGeneration); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + registerTestAgentProvider(svc, agent); + agent.catalog = undefined; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + const stored = await database.getSessionV2(session.toString()); + assert.deepStrictEqual({ + payloadVersion: stored?.payloadVersion, + isRead: catalogDataOf(stored)?.isRead, + catalogCalls: agent.catalogCalls, + }, { + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + isRead: false, + catalogCalls: 0, + }); + }); + + test('restores side effects for newly imported external and host-created sessions without surfacing adoptable sessions', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const external = AgentSession.uri('copilot', 'untitled-external-import'); + const hostCreated = AgentSession.uri('copilot', 'host-created-import'); + const adoptable = AgentSession.uri('copilot', 'adoptable-side-effect-import'); + await perSession.database(hostCreated).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'true'); + const svc = createService(database, perSession.service); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [ + { ...metadata(external), summary: undefined }, + metadata(hostCreated), + metadata(adoptable, withSessionEhcliAdoptable(undefined)), + ]; + const scheduledTitles: string[] = []; + let reconciliationCalls = 0; + (svc as unknown as { _scheduleExternalSessionTitles(sessions: readonly IAgentSessionMetadata[]): void })._scheduleExternalSessionTitles = sessions => { + scheduledTitles.push(...sessions.map(session => session.session.toString())); + }; + (svc as unknown as { _queueSessionListReconciliation(): void })._queueSessionListReconciliation = () => { + reconciliationCalls++; + }; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + assert.deepStrictEqual({ + scheduledTitles, + reconciliationCalls, + surfaced: [hostCreated, adoptable].map(session => getStateManager(svc).getSurfacedSessionSummary(session.toString())?.resource), + adoptionCalls: agent.adoptionCalls, + }, { + scheduledTitles: [external.toString()], + reconciliationCalls: 1, + surfaced: [hostCreated.toString(), undefined], + adoptionCalls: 0, + }); + }); + + test('keeps independent progress across provider and session failures before marking complete', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + let failOneSession = true; + const failing = AgentSession.uri('copilot', 'transient-failure'); + const sibling = AgentSession.uri('copilot', 'verified-sibling'); + const sessionData: ISessionDataService = { + ...perSession.service, + tryOpenDatabase: async session => { + if (failOneSession && session.toString() === failing.toString()) { + throw new Error('transient per-session probe failure'); + } + return perSession.service.tryOpenDatabase(session); + }, + }; + const svc = createService(database, sessionData); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = undefined; + registerTestAgentProvider(svc, agent); + await assert.rejects(svc.listSessions(), /cannot enumerate its native session catalog yet/); + const markerWhileUnavailable = await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + + agent.catalog = [metadata(failing), metadata(sibling)]; + await svc.listSessions(); + const afterFailedPass = { + catalog: (await database.listSessionsV2()).map(row => row.session), + marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + }; + + failOneSession = false; + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, true); + + assert.deepStrictEqual({ + markerWhileUnavailable, + afterFailedPass, + finalCatalog: (await database.listSessionsV2()).map(row => row.session).sort(), + finalMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + }, { + markerWhileUnavailable: false, + afterFailedPass: { catalog: [sibling.toString()], marker: false }, + finalCatalog: [failing.toString(), sibling.toString()].sort(), + finalMarker: true, + }); + }); + + test('provider-absent historical incomplete v2 rows are terminal, fast-skipped, and revived by discovery', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const absent = AgentSession.uri('copilot', 'provider-absent-legacy'); + await database.registerSession(absent.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: true }); + await database.registerSessionV2(absent.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: true }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = []; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + await (svc as unknown as { _awaitInitialProviderMigrationForProvider(provider: IAgent): Promise })._awaitInitialProviderMigrationForProvider(agent); + const exclusionAfterEnumeration = await database.getSessionsV2Exclusion('copilot', absent.toString()); + const incompleteIdentityAfterEnumeration = await database.getSessionV2Registration(absent.toString()); + perSession.databaseOpens.length = 0; + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + const markerFastPass = { + catalogCalls: agent.catalogCalls, + metadataCalls: agent.metadataCalls, + databaseOpens: [...perSession.databaseOpens], + }; + + agent.fireDiscoveredChats([{ ...metadata(absent), external: true }]); + for (let i = 0; i < 50 && !await database.getSessionV2(absent.toString()); i++) { + await timeout(0); + } + + assert.deepStrictEqual({ + exclusionAfterEnumeration, + incompleteIdentityAfterEnumeration, + marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + markerFastPass, + revivedExclusion: await database.getSessionsV2Exclusion('copilot', absent.toString()), + revived: (await database.getSessionV2(absent.toString()))?.session, + }, { + exclusionAfterEnumeration: { + provider: 'copilot', + session: absent.toString(), + reason: 'providerAbsent', + fingerprint: 'enumeration-v1', + }, + incompleteIdentityAfterEnumeration: undefined, + marker: true, + markerFastPass: { catalogCalls: 1, metadataCalls: 2, databaseOpens: [] }, + revivedExclusion: undefined, + revived: absent.toString(), + }); + }); + + test('provider-absent stale exclusion withholds migration completion', async () => { + class RacingExclusionDatabase extends TransientRegistryWriteDatabase { + private _raceExclusion = true; + + override async excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion, expected: IAgentHostDatabaseSessionsV2ExclusionExpectation): Promise<'excluded' | 'stale'> { + if (this._raceExclusion && exclusion.reason === 'providerAbsent') { + this._raceExclusion = false; + await this.updateSessionModifiedTime(exclusion.session, 2); + } + return super.excludeSessionV2(exclusion, expected); + } + } + const database = new RacingExclusionDatabase(); + const perSession = createPerSessionDataService(); + const absent = AgentSession.uri('copilot', 'provider-absent-stale-cas'); + await database.registerSessionV2(absent.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: true }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = []; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + await (svc as unknown as { _awaitInitialProviderMigrationForProvider(provider: IAgent): Promise })._awaitInitialProviderMigrationForProvider(agent); + + assert.deepStrictEqual({ + marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + exclusion: await database.getSessionsV2Exclusion('copilot', absent.toString()), + currentModifiedTime: (await database.getSessionV2Registration(absent.toString()))?.modifiedTime, + }, { + marker: false, + exclusion: undefined, + currentModifiedTime: 2, + }); + }); + + test('provider-absent exclusion uses the post-reconciliation identity', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const absent = AgentSession.uri('copilot', 'provider-absent-after-reconcile'); + await database.registerSessionV2(absent.toString(), { + provider: 'copilot', + startTime: 20, + modifiedTime: 40, + source: 'restore', + }, { checkTombstone: true }); + await database.registerSession(absent.toString(), { + provider: 'copilot', + startTime: 10, + modifiedTime: 30, + source: 'discovery', + }, { checkTombstone: true }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = []; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + await (svc as unknown as { _awaitInitialProviderMigrationForProvider(provider: IAgent): Promise })._awaitInitialProviderMigrationForProvider(agent); + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, true); + + assert.deepStrictEqual({ + marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + exclusion: await database.getSessionsV2Exclusion('copilot', absent.toString()), + current: await database.getSessionV2Registration(absent.toString()), + reconcileAttempts: database.sessionV2ReconcileAttempts, + }, { + marker: true, + exclusion: { + provider: 'copilot', + session: absent.toString(), + reason: 'providerAbsent', + fingerprint: 'enumeration-v1', + }, + current: undefined, + reconcileAttempts: 1, + }); + }); + + test('verified current rows with matching receipts remain authoritative when enumeration omits them', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const verified = AgentSession.uri('copilot', 'verified-provider-absent'); + await seedVerifiedSessionV2(database, perSession.database(verified), verified, true); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = []; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, true); + perSession.databaseOpens.length = 0; + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + assert.deepStrictEqual({ + exclusion: await database.getSessionsV2Exclusion('copilot', verified.toString()), + registration: (await database.getSessionV2Registration(verified.toString()))?.session, + projection: (await database.getSessionV2(verified.toString()))?.session, + marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + catalogCalls: agent.catalogCalls, + metadataCalls: agent.metadataCalls, + markerFastDatabaseOpens: perSession.databaseOpens, + }, { + exclusion: undefined, + registration: verified.toString(), + projection: verified.toString(), + marker: true, + catalogCalls: 1, + metadataCalls: 0, + markerFastDatabaseOpens: [], + }); + }); + + test('stale external exclusions are durable and revived by fresh discovery', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const stale = AgentSession.uri('copilot', 'stale-external'); + const staleModifiedTime = Date.now() - 31 * 24 * 60 * 60 * 1000; + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [metadata(stale)]; + agent.catalog = [{ ...agent.catalog[0], modifiedTime: staleModifiedTime }]; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + const staleExclusion = await database.getSessionsV2Exclusion('copilot', stale.toString()); + perSession.databaseOpens.length = 0; + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + const markerFastDatabaseOpens = [...perSession.databaseOpens]; + agent.fireDiscoveredChats([{ ...metadata(stale), external: true }]); + for (let i = 0; i < 50 && !(await database.getSessionV2(stale.toString())); i++) { + await timeout(0); + } + + assert.deepStrictEqual({ + staleExclusion, + markerFastCatalogCalls: agent.catalogCalls, + markerFastDatabaseOpens, + revivedExclusion: await database.getSessionsV2Exclusion('copilot', stale.toString()), + revived: (await database.getSessionV2(stale.toString()))?.session, + }, { + staleExclusion: { + provider: 'copilot', + session: stale.toString(), + reason: 'staleExternal', + fingerprint: String(staleModifiedTime), + }, + markerFastCatalogCalls: 1, + markerFastDatabaseOpens: [], + revivedExclusion: undefined, + revived: stale.toString(), + }); + }); + + test('permanently excludes tombstones, chat backings, and subagents without adopting lazy sessions', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const tombstoned = AgentSession.uri('copilot', 'tombstoned-import'); + const backing = AgentSession.uri('copilot', 'backing-import'); + const subagent = URI.parse(buildSubagentSessionUri(AgentSession.uri('copilot', 'parent-import'), 'tool-call')); + const adoptable = AgentSession.uri('copilot', 'adoptable-import'); + await database.markSessionTombstoned(tombstoned.toString()); + await database.registerSession(backing.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: true }); + await perSession.database(backing).setMetadata('peerChatBacking', 'true'); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [ + metadata(tombstoned), + metadata(backing), + metadata(subagent), + metadata(adoptable, withSessionEhcliAdoptable(undefined)), + ]; + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + const exclusions = await database.listSessionsV2Exclusions('copilot'); + perSession.databaseOpens.length = 0; + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + const catalog = await database.listSessionsV2(); + assert.deepStrictEqual({ + registrations: (await database.listSessionV2Registrations()).map(row => row.session), + catalog: catalog.map(row => ({ + session: row.session, + adoptable: readSessionEhcliAdoptable(catalogDataOf(row)?._meta), + adopted: readSessionEhcliAdopted(catalogDataOf(row)?._meta), + })), + adoptionCalls: agent.adoptionCalls, + exclusions: exclusions.map(exclusion => ({ session: exclusion.session, reason: exclusion.reason })).sort((a, b) => a.session.localeCompare(b.session)), + markerFastCatalogCalls: agent.catalogCalls, + markerFastDatabaseOpens: perSession.databaseOpens, + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + }, { + registrations: [adoptable.toString()], + catalog: [{ session: adoptable.toString(), adoptable: true, adopted: false }], + adoptionCalls: 0, + exclusions: [ + { session: backing.toString(), reason: 'backing' }, + { session: subagent.toString(), reason: 'subagent' }, + ].sort((a, b) => a.session.localeCompare(b.session)), + markerFastCatalogCalls: 1, + markerFastDatabaseOpens: [], + currentMarker: true, + }); + }); + }); + + test('legacy migration and external discovery use separate provider catalogs and signals', async () => { + class SeparateCatalogAgent extends MockAgent { + private readonly _onDidDiscoverChats = new Emitter(); + override readonly onDidDiscoverChats = this._onDidDiscoverChats.event; + externalCalls = 0; + legacyCalls = 0; + + override async listExternalChats(): Promise { + this.externalCalls++; + return [{ chat: URI.parse(buildDefaultChatUri(external)), startTime: Date.now(), modifiedTime: Date.now() }]; + } + + override async listChatsToMigrate(): Promise { + this.legacyCalls++; + return [{ chat: URI.parse(buildDefaultChatUri(legacy)), startTime: Date.now(), modifiedTime: Date.now() }]; + } + + override fireDiscoveredChats(chats: readonly IAgentDiscoveredChat[]): void { this._onDidDiscoverChats.fire(chats); } + + override dispose(): void { + this._onDidDiscoverChats.dispose(); + super.dispose(); + } + } + + const external = AgentSession.uri('copilot', 'external-catalog'); + const legacy = AgentSession.uri('copilot', 'legacy-catalog'); + const sessionData = createPerSessionDataService(); + await sessionData.database(legacy).setMetadata(AH_META_WORKSPACELESS_DB_KEY, 'false'); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new SeparateCatalogAgent('copilot')); registerTestAgentProvider(svc, agent); await svc.listSessions(); @@ -4700,7 +7916,7 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(agent.listExternalChatsCalls, 1, 'ordinary list refreshes must not re-enumerate the provider'); }); - test('concurrent listSessions calls share one registry discovery pass', async () => { + test('concurrent first listings serve registered fallback data while sharing background migration', async () => { const gate = new DeferredPromise(); class GatedListAgent extends MockAgent { override readonly onDidDiscoverChats = Event.None; @@ -4711,18 +7927,30 @@ suite('AgentService (node dispatcher)', () => { return super.listExternalChats(); } } - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const legacy = AgentSession.uri('copilot', 'legacy-concurrent'); + const database = new TransientRegistryWriteDatabase(); + await database.registerRuntimeSession(legacy.toString(), { + provider: 'copilot', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, database)); getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new GatedListAgent('copilot')); - registerTestAgentProvider(svc, agent); - const legacy = AgentSession.uri('copilot', 'legacy-concurrent'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); + registerTestAgentProvider(svc, agent); const first = svc.listSessions(); const second = svc.listSessions(); for (let i = 0; i < 20 && agent.listCalls === 0; i++) { await timeout(0); } + let listingSettled = false; + void first.then(() => listingSettled = true); + for (let i = 0; i < 20 && !listingSettled; i++) { + await timeout(0); + } + assert.strictEqual(listingSettled, true, 'first listing must not wait for provider migration'); gate.complete(); const [firstResult, secondResult] = await Promise.all([first, second]); @@ -4737,6 +7965,46 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('first listing recomputes when background migration changes the registry before fallback returns', async () => { + const fallbackStarted = new DeferredPromise(); + const releaseFallback = new DeferredPromise(); + const existing = AgentSession.uri('copilot', 'existing-during-migration'); + const discovered = AgentSession.uri('copilot', 'discovered-during-migration'); + const database = new TransientRegistryWriteDatabase(); + await database.registerRuntimeSession(existing.toString(), { + provider: 'copilot', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, database)); + getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); + const agent = disposables.add(new MockAgent('copilot')); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(existing), existing); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(discovered), discovered); + const internals = svc as unknown as { + _legacyRegisteredSessionMetadata(registered: IRegisteredSession): Promise; + }; + const originalFallback = internals._legacyRegisteredSessionMetadata.bind(svc); + internals._legacyRegisteredSessionMetadata = async registered => { + fallbackStarted.complete(); + await releaseFallback.p; + return originalFallback(registered); + }; + registerTestAgentProvider(svc, agent); + + const listing = svc.listSessions(); + await fallbackStarted.p; + for (let i = 0; i < 50 && !await database.getSessionV2(discovered.toString()); i++) { + await timeout(0); + } + releaseFallback.complete(); + + assert.deepStrictEqual( + (await listing).map(session => session.session.toString()).sort(), + [existing.toString(), discovered.toString()].sort(), + ); + }); + test('a readiness signal retries provider-native discovery after a transient provider failure', async () => { class TransientListFailureAgent extends MockAgent { private _failList = true; @@ -4858,7 +8126,7 @@ suite('AgentService (node dispatcher)', () => { // The first discovery completes with no native chats. await svc.listSessions(); assert.deepStrictEqual(await svc.getRegisteredSessions(), []); - assert.strictEqual(await svc.isProviderRegistryBackfilled('copilot'), true); + assert.strictEqual(await svc.isProviderRegistryBackfilled('copilot'), false); // The provider's native catalog now has a session and reports the change. const legacy = AgentSession.uri('copilot', 'legacy-became-enumerable'); @@ -4876,8 +8144,11 @@ suite('AgentService (node dispatcher)', () => { test('does not surface adoptable legacy sessions (they belong to the extension host until opened)', async () => { class AdoptableLegacyAgent extends MockAgent { + override readonly onDidDiscoverChats = Event.None; + catalog: readonly IAgentChatMetadata[] = []; + override async listChatsToMigrate(): Promise { - return this.listExternalChats(); + return [...this.catalog]; } override async getChatMetadata(): Promise { @@ -4887,11 +8158,10 @@ suite('AgentService (node dispatcher)', () => { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new AdoptableLegacyAgent('copilot')); const legacy = AgentSession.uri('copilot', 'adoptable-legacy'); - (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); - agent.sessionMetadataOverrides = { _meta: withSessionEhcliAdoptable(undefined) }; - registerTestAgentProvider(svc, agent); + agent.catalog = [{ ...discoveredChat(legacy), _meta: withSessionEhcliAdoptable(undefined) }]; getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - await svc.listSessions(); + registerTestAgentProvider(svc, agent); + await (svc as unknown as { _awaitInitialProviderMigrationForProvider(provider: IAgent): Promise })._awaitInitialProviderMigrationForProvider(agent); // Un-adopted adoptable-legacy rows stay with the extension-host provider until // opened, so the agent host never surfaces them even with the gate on. @@ -5057,7 +8327,7 @@ suite('AgentService (node dispatcher)', () => { registerTestAgentProvider(svc, deferred); const listedWhileDeferred = (await svc.listSessions()).map(session => session.session.toString()); - const markerWhileDeferred = await db.isProviderBackfilled('claude'); + const markerWhileDeferred = await db.isSessionsV2Backfilled('claude', AGENT_HOST_CATALOG_PAYLOAD_VERSION); assert.deepStrictEqual({ listedWhileDeferred, markerWhileDeferred }, { listedWhileDeferred: [healthySession.toString()], markerWhileDeferred: false, @@ -5066,12 +8336,12 @@ suite('AgentService (node dispatcher)', () => { deferred.ready = true; (deferred as unknown as { _sessions: Map })._sessions.set(AgentSession.id(deferredSession), deferredSession); deferred.fireDiscoveredChats([discoveredChat(deferredSession)]); - for (let i = 0; i < 50 && !(await db.isProviderBackfilled('claude')); i++) { + for (let i = 0; i < 50 && !(await db.isSessionsV2Backfilled('claude', AGENT_HOST_CATALOG_PAYLOAD_VERSION)); i++) { await timeout(0); } assert.deepStrictEqual({ - markerAfterReadiness: await db.isProviderBackfilled('claude'), + markerAfterReadiness: await db.isSessionsV2Backfilled('claude', AGENT_HOST_CATALOG_PAYLOAD_VERSION), catalogCalls: deferred.catalogCalls, listedAfterReadiness: (await svc.listSessions()).map(session => session.session.toString()).sort(), }, { @@ -5103,19 +8373,25 @@ suite('AgentService (node dispatcher)', () => { agent.ready = true; (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + // A failed candidate is absorbed as an incomplete import rather than + // failing the whole listing, and the backfill marker stays unset so a + // later forced pass re-enumerates instead of short-circuiting. db.failRegistryWrites(1); - await assert.rejects(svc.listSessions(), /transient registry write failure/); + const listedAfterFailure = (await svc.listSessions()).map(candidate => candidate.session.toString()); + const markerAfterFailure = await db.isSessionsV2Backfilled('claude', AGENT_HOST_CATALOG_PAYLOAD_VERSION); + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, true); assert.deepStrictEqual({ - markerAfterFailure: await db.isProviderBackfilled('claude'), + listedAfterFailure, + markerAfterFailure, listedAfterRetry: (await svc.listSessions()).map(candidate => candidate.session.toString()), - markerAfterRetry: await db.isProviderBackfilled('claude'), - catalogCalls: agent.catalogCalls, + markerAfterRetry: await db.isSessionsV2Backfilled('claude', AGENT_HOST_CATALOG_PAYLOAD_VERSION), }, { + listedAfterFailure: [], markerAfterFailure: false, listedAfterRetry: [session.toString()], markerAfterRetry: true, - catalogCalls: 3, }); }); @@ -5129,7 +8405,22 @@ suite('AgentService (node dispatcher)', () => { const existing = AgentSession.uri('copilot', 'existing-before-unavailable'); await db.registerSession(existing.toString(), { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); const writesBeforeUnavailable = db.registryWriteAttempts; - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + const svc = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + db, + )); getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new NotYetMigratableAgent('copilot')); const legacy = AgentSession.uri('copilot', 'legacy-migration-not-ready'); @@ -5160,7 +8451,7 @@ suite('AgentService (node dispatcher)', () => { registered: (await svc.getRegisteredSessions()).map(session => session.toString()), }, { registryWrites: writesBeforeUnavailable, - registered: [existing.toString()], + registered: [], }); agent.enumerable = true; @@ -5251,7 +8542,7 @@ suite('AgentService (node dispatcher)', () => { } } const db = new TransientRegistryWriteDatabase(); - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createPerSessionDataService().service, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); const copilot = disposables.add(new CatalogAgent('copilot')); @@ -5267,14 +8558,18 @@ suite('AgentService (node dispatcher)', () => { await assert.rejects(Promise.all([svc.listSessions(), svc.listSessions()]), /cannot enumerate its native session catalog yet/); const callsAfterFailure = { copilot: copilot.catalogCalls, claude: claude.catalogCalls }; claude.available = true; + await svc.listSessions(); + for (let i = 0; i < 50 && !await db.isSessionsV2Backfilled('claude', AGENT_HOST_CATALOG_PAYLOAD_VERSION); i++) { + await timeout(0); + } const [first, second] = await Promise.all([svc.listSessions(), svc.listSessions()]); assert.deepStrictEqual({ callsAfterFailure, finalCalls: { copilot: copilot.catalogCalls, claude: claude.catalogCalls }, backfilled: { - copilot: await db.isProviderBackfilled('copilot'), - claude: await db.isProviderBackfilled('claude'), + copilot: await db.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), + claude: await db.isSessionsV2Backfilled('claude', AGENT_HOST_CATALOG_PAYLOAD_VERSION), }, first: first.map(session => session.session.toString()).sort(), second: second.map(session => session.session.toString()).sort(), @@ -5352,7 +8647,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(registered.map(s => s.toString()), [legacy.toString()]); }); - test('the legacy global backfill marker is never auto-mirrored, even once every currently-registered provider is backfilled', async () => { + test('legacy migration markers are never written by current provider imports', async () => { // The bug this guards: mirroring the legacy global marker once // "every known provider" was backfilled was unsafe because a // provider (e.g. Codex) can register later than that point — a @@ -5363,7 +8658,7 @@ suite('AgentService (node dispatcher)', () => { const early = disposables.add(new MockAgent('copilot')); registerTestAgentProvider(svc, early); await svc.listSessions(); - assert.strictEqual(await svc.isProviderRegistryBackfilled('copilot'), true); + assert.strictEqual(await svc.isProviderRegistryBackfilled('copilot'), false); assert.strictEqual(await svc.isLegacyRegistryBackfilled(), false, 'the legacy global marker must never be written automatically'); // A late-registering provider (simulating Codex enabling after @@ -5371,7 +8666,7 @@ suite('AgentService (node dispatcher)', () => { const late = disposables.add(new MockAgent('claude')); registerTestAgentProvider(svc, late); await svc.listSessions(); - assert.strictEqual(await svc.isProviderRegistryBackfilled('claude'), true); + assert.strictEqual(await svc.isProviderRegistryBackfilled('claude'), false); // Even with every currently-registered provider backfilled, the // legacy global marker is still never mirrored — so a downgrade @@ -5515,7 +8810,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual((await svc.listSessions()).map(session => session.session.toString()), [legacy.toString()]); assert.ok(agent.listExternalChatsCalls >= 1); - assert.strictEqual(await svc.isProviderRegistryBackfilled('copilot'), true); + assert.strictEqual(await svc.isProviderRegistryBackfilled('copilot'), false); assert.deepStrictEqual((await svc.getRegisteredSessions()).map(session => session.toString()), [legacy.toString()]); }); @@ -5720,7 +9015,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - testWithExternalSessionClock('lazy provider fallback filters and sorts external sessions by their last modified time', async () => { + testWithExternalSessionClock('lazy provider fallback filters external sessions by their last modified time', async () => { class InactiveMetadataAgent extends MockAgent { override async getChatMetadata(chat: URI, _context: URI | IAgentChatContext, _providerData?: string, options?: IAgentChatMetadataOptions): Promise { return options?.registryFallback ? { chat, ...options.registryFallback } : undefined; @@ -5749,16 +9044,19 @@ suite('AgentService (node dispatcher)', () => { const listed = await svc.listSessions(AgentHostExternalSessionsMode.Last24Hours); + // The central catalog lists registry identities in a stable + // session-uri order, so recency selects *which* sessions survive the + // filter rather than the order they are returned in. assert.deepStrictEqual(listed.map(session => ({ id: AgentSession.id(session.session), modifiedTime: session.modifiedTime, - })), [ + })).sort((a, b) => b.modifiedTime - a.modifiedTime), [ { id: 'old-recently-used', modifiedTime: now - 5 * 60 * 1000 }, { id: 'new-less-recently-used', modifiedTime: now - 30 * 60 * 1000 }, ]); }); - test('listSessions does not synthesize registry metadata for other providers', async () => { + test('listSessions serves a registered session from the catalog when provider metadata is unavailable', async () => { class MissingMetadataAgent extends MockAgent { metadataAvailable = true; override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { @@ -5770,10 +9068,13 @@ suite('AgentService (node dispatcher)', () => { registerTestAgentProvider(svc, agent); const session = await svc.createSession({ provider: 'copilot' }); + await svc.whenCatalogReconciliationIdle(); getStateManager(svc).deleteSession(session.toString()); agent.metadataAvailable = false; - assert.strictEqual((await svc.listSessions()).some(candidate => candidate.session.toString() === session.toString()), false); + // The durable catalog row is authoritative, so the session survives a + // provider that can no longer produce metadata rather than vanishing. + assert.strictEqual((await svc.listSessions()).some(candidate => candidate.session.toString() === session.toString()), true); }); test('session registry stays in parity with listSessions across create/delete', async () => { @@ -5840,6 +9141,39 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions[0].summary, 'My Custom Title'); }); + test('first fallback listing uses the default chat title before background migration completes', async () => { + const migrationGate = new DeferredPromise(); + class DelayedMigrationAgent extends MockAgent { + override readonly onDidDiscoverChats = Event.None; + override async listChatsToMigrate(): Promise { + await migrationGate.p; + return this.listExternalChats(); + } + } + const session = AgentSession.uri('copilot', 'chat-local-title'); + const sessionData = createPerSessionDataService(); + await sessionData.database(URI.parse(buildDefaultChatUri(session))).setMetadata(SESSION_CUSTOM_TITLE_KEY, 'Actual Chat Title'); + const database = new TransientRegistryWriteDatabase(); + await database.registerRuntimeSession(session.toString(), { + provider: 'copilot', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, database)); + const agent = disposables.add(new DelayedMigrationAgent('copilot')); + agent.sessionMetadataOverrides = { summary: 'Provider Title' }; + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + registerTestAgentProvider(svc, agent); + + const listed = await svc.listSessions(AgentHostExternalSessionsMode.Last30Days); + migrationGate.complete(); + + assert.deepStrictEqual(listed.map(item => ({ session: item.session.toString(), summary: item.summary })), [{ + session: session.toString(), + summary: 'Actual Chat Title', + }]); + }); + test('listSessions overlays the AH-owned workspaceless marker for any agent', async () => { // The AH service owns `agentHost.workspaceless` in the central session // database and overlays it onto every agent's summary `_meta` — so an @@ -5984,21 +9318,45 @@ suite('AgentService (node dispatcher)', () => { worktreeRootResolutions++; return []; }; - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, gitService)); + const catalogDatabase = new TestAgentHostOrchestratorDatabase(); + const svc = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, + gitService, + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); registerTestAgentProvider(svc, agent); + // The first listing predates the catalog row and falls back; the second is served centrally. const sessions = await svc.listSessions(); - // Twice, because the deleted repair cached per session: one listing cannot tell "never resolves" from "resolves once". - await svc.listSessions(); + await svc.whenCatalogReconciliationIdle(); + const stored = await catalogDatabase.getSessionV2(sessionUri.toString()); + const decoded = stored && decodeAgentHostCatalogPayload(stored.payload); + (svc as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); + const centralSessions = await svc.listSessions(); assert.deepStrictEqual({ worktreeRootResolutions, project: sessions[0].project && { uri: sessions[0].project.uri.toString(), displayName: sessions[0].project.displayName }, + centralProject: centralSessions[0].project && { uri: centralSessions[0].project.uri.toString(), displayName: centralSessions[0].project.displayName }, + storedProject: decoded && decoded.ok ? decoded.value.data.project : undefined, persistedRepositoryRoot: await db.getMetadata(WORKTREE_META_REPOSITORY_ROOT), }, { worktreeRootResolutions: 0, project: { uri: linkedCheckout.toString(), displayName: 'parent' }, + centralProject: { uri: linkedCheckout.toString(), displayName: 'parent' }, + storedProject: { uri: linkedCheckout.toString(), displayName: 'parent' }, persistedRepositoryRoot: linkedCheckout.toString(), }); }); @@ -6102,6 +9460,10 @@ suite('AgentService (node dispatcher)', () => { copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' }; await service.createSession({ provider: 'copilot' }); + // The catalog is authoritative for the listing, so a title only the + // provider knows surfaces once reconciliation has folded it in. + await service.whenCatalogReconciliationIdle(); + (service as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); const sessions = await service.listSessions(); assert.strictEqual(sessions.length, 1); @@ -6252,34 +9614,60 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('listSessions overlays live workspace metadata over a stale provider snapshot', async () => { - class DelayedListAgent extends MockAgent { + test('listSessions overlays live workspace metadata over a stale catalog snapshot', async () => { + // The listing is blocked mid-flight on the catalog read, so live + // state that lands while it runs must still win over the snapshot. + class DelayedCatalogDatabase extends TestAgentHostOrchestratorDatabase { readonly listStarted = new DeferredPromise(); readonly releaseList = new DeferredPromise(); - override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { - const snapshot = await super.getChatMetadata(chat, context); - this.listStarted.complete(); - await this.releaseList.p; - return snapshot; + deferReads = false; + + override async getSessionV2(session: string): Promise { + const row = await super.getSessionV2(session); + if (this.deferReads) { + this.listStarted.complete(); + await this.releaseList.p; + } + return row; } } - const agent = new DelayedListAgent('copilot'); + const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); agent.resolvedWorkingDirectory = URI.file('/original'); + const catalogDatabase = new DelayedCatalogDatabase(); + const svc = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); const { session } = await createAgentSession(agent); - setExternalSessionsMode(service, AgentHostExternalSessionsMode.Last30Days, 1); - await waitForSessionListReconciliation(service); - registerTestAgentProvider(service, agent); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); + await waitForSessionListReconciliation(svc); + registerTestAgentProvider(svc, agent); agent.fireDiscoveredChats([discoveredChat(session)]); - for (let i = 0; i < 50 && (await service.getRegisteredSessions()).length === 0; i++) { + for (let i = 0; i < 50 && (await svc.getRegisteredSessions()).length === 0; i++) { await timeout(0); } + await svc.whenCatalogReconciliationIdle(); + (svc as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); + catalogDatabase.deferReads = true; - const listing = service.listSessions(); - await agent.listStarted.p; + const listing = svc.listSessions(); + await catalogDatabase.listStarted.p; const summaryNow = Date.now(); - getStateManager(service).restoreSession({ + getStateManager(svc).restoreSession({ resource: session.toString(), provider: 'copilot', title: 'Materialized', @@ -6289,7 +9677,7 @@ suite('AgentService (node dispatcher)', () => { project: { uri: URI.file('/project').toString(), displayName: 'project' }, workingDirectories: [URI.file('/worktree').toString()], }, []); - agent.releaseList.complete(); + catalogDatabase.releaseList.complete(); const listed = (await listing).find(item => item.session.toString() === session.toString()); assert.deepStrictEqual({ @@ -7634,12 +11022,14 @@ suite('AgentService (node dispatcher)', () => { const readsAfterAmbientListing = { ambient: agent.ambientReads, restore: agent.restoreReads }; await svc.restoreSession(session); + // The central catalog serves ambient listings without a provider + // round-trip; only an explicit restore activates a metadata read. assert.deepStrictEqual({ readsAfterAmbientListing, readsAfterRestore: { ambient: agent.ambientReads, restore: agent.restoreReads }, }, { - readsAfterAmbientListing: { ambient: 1, restore: 0 }, - readsAfterRestore: { ambient: 1, restore: 1 }, + readsAfterAmbientListing: { ambient: 0, restore: 0 }, + readsAfterRestore: { ambient: 0, restore: 1 }, }); }); @@ -7986,16 +11376,16 @@ suite('AgentService (node dispatcher)', () => { }); test('reports a known (registered) session whose provider is currently unavailable as internal error, not not-found', async () => { - // Reviewer scenario (#331721): on a backfilled restart the one-time - // migration short-circuits without contacting the provider, so a + // Reviewer scenario (#331721): on a backfilled restart the initial + // provider enumeration short-circuits, so a // provider that cannot currently describe the session (e.g. Claude // whose SDK is not downloaded yet) returns `undefined`. Because the // session is known to the registry, that miss must be transient, not // the sticky false not-found. const db = new TransientRegistryWriteDatabase(); const session = AgentSession.uri('copilot', 'registered-but-unavailable'); - await db.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); - await db.markProviderBackfilled('copilot'); + await db.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); + await db.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION); const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); const agent = disposables.add(new StartupRaceAgent('copilot')); agent.migrationGate.complete(); @@ -8404,7 +11794,11 @@ suite('AgentService (node dispatcher)', () => { async ensureChatAdopted(_chat: URI, _context: URI | IAgentChatContext): Promise { this.adoptCalls++; this._adopted = true; - return { adopted: true, eligible: true }; + return { + adopted: true, + eligible: true, + listVisible: { title: 'Adopted Legacy Title', titleSource: 'user', isRead: true }, + }; } override async getChatMetadata(chat: URI, _context: URI | IAgentChatContext): Promise { // Un-adopted: no backend metadata yet (mirrors the real gap). @@ -8434,7 +11828,8 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(offAgent.adoptCalls, 0); // Migrate setting on at startup: opening adopts in place. - const onService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const onDatabase = new TestSessionDatabase(); + const onService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(onDatabase), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const onAgent = disposables.add(new AdoptOnOpenAgent()); registerTestAgentProvider(onService, onAgent); onAgent.sessionMessages = []; @@ -8443,9 +11838,28 @@ suite('AgentService (node dispatcher)', () => { surface(onService, onSession); await onService.restoreSession(onSession); + const summary = getStateManager(onService).getSessionSummary(onSession.toString()); assert.deepStrictEqual( - { adoptCalls: onAgent.adoptCalls, restored: !!getStateManager(onService).getSessionState(onSession.toString()) }, - { adoptCalls: 1, restored: true }, + { + adoptCalls: onAgent.adoptCalls, + restored: !!getStateManager(onService).getSessionState(onSession.toString()), + title: summary?.title, + isRead: summary !== undefined && (summary.status & SessionStatus.IsRead) !== 0, + persistedTitle: await onDatabase.getMetadata(SESSION_CUSTOM_TITLE_KEY), + persistedTitleSource: await onDatabase.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + persistedIsRead: await onDatabase.getMetadata(AH_META_IS_READ_DB_KEY), + hasCatalogSyncSnapshot: await onDatabase.getCatalogSyncSnapshot() !== undefined, + }, + { + adoptCalls: 1, + restored: true, + title: 'Adopted Legacy Title', + isRead: true, + persistedTitle: 'Adopted Legacy Title', + persistedTitleSource: 'user', + persistedIsRead: 'true', + hasCatalogSyncSnapshot: true, + }, ); }); @@ -8607,6 +12021,159 @@ suite('AgentService (node dispatcher)', () => { } }); + test('an adopted surfaced session continues syncing mutations and survives restart', async () => { + class AdoptOnOpenAgent extends MockAgent { + private _adopted = false; + + constructor() { + super('copilot'); + } + + async ensureChatAdopted(_chat: URI, _context: URI | IAgentChatContext): Promise { + this._adopted = true; + return { adopted: true, eligible: true }; + } + + override async getChatMetadata(chat: URI): Promise { + return this._adopted ? { chat, startTime: 1, modifiedTime: 1 } : undefined; + } + } + + const sessionDatabase = new TestSessionDatabase(); + const catalogDatabase = new TestAgentHostOrchestratorDatabase(); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(sessionDatabase), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); + const agent = disposables.add(new AdoptOnOpenAgent()); + registerTestAgentProvider(localService, agent); + getConfigurationService(localService).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + const session = AgentSession.uri('copilot', 'adopted-surfaced-sync'); + const sessionString = session.toString(); + getStateManager(localService).announceSurfacedSession({ + resource: sessionString, + provider: 'copilot', + title: 'Legacy', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + _meta: withSessionEhcliAdoptable(undefined), + }); + + await localService.restoreSession(session); + localService.dispatchAction(sessionString, { type: ActionType.SessionTitleChanged, title: 'Renamed' }, 'test-client', 1, AgentHostClientType.EditorWindow); + getStateManager(localService).dispatchServerAction(sessionString, { type: ActionType.SessionIsReadChanged, isRead: true }); + getStateManager(localService).dispatchServerAction(sessionString, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + const state = getStateManager(localService).getSessionState(sessionString); + getStateManager(localService).setSessionMeta(sessionString, withSessionArtifacts(state?._meta, [{ + id: 'artifact', + type: SessionArtifactType.Website, + label: 'Artifact', + link: 'https://example.com', + isArtifact: true, + }])); + await (localService as unknown as { _persistOrderedListVisibleSessionState(session: URI, metadataOverrides: Readonly>): Promise })._persistOrderedListVisibleSessionState(session, { + [AH_META_IS_READ_DB_KEY]: 'true', + [AH_META_IS_ARCHIVED_DB_KEY]: 'true', + [SESSION_ARTIFACTS_KEY]: JSON.stringify([{ + id: 'artifact', + type: SessionArtifactType.Website, + label: 'Artifact', + link: 'https://example.com', + }]), + }); + await localService.whenCatalogReconciliationIdle(); + const persisted = await catalogDatabase.getSessionV2(sessionString); + + const restartedService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(sessionDatabase), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); + const [restarted] = await restartedService.listSessions(); + + assert.deepStrictEqual({ + title: restarted.summary, + isRead: ((restarted.status ?? SessionStatus.Idle) & SessionStatus.IsRead) !== 0, + isArchived: ((restarted.status ?? SessionStatus.Idle) & SessionStatus.IsArchived) !== 0, + artifacts: readSessionArtifacts(restarted._meta), + persistedArtifacts: catalogDataOf(persisted)?._meta?.[SESSION_META_ARTIFACTS_KEY], + }, { + title: 'Renamed', + isRead: true, + isArchived: true, + artifacts: [{ + id: 'artifact', + type: SessionArtifactType.Website, + label: 'Artifact', + isArtifact: true, + link: 'https://example.com', + }], + persistedArtifacts: [{ + id: 'artifact', + type: SessionArtifactType.Website, + label: 'Artifact', + isArtifact: true, + link: 'https://example.com', + }], + }); + }); + + test('catalog suppression defers explicit metadata overrides without dropping them', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + registerTestAgentProvider(localService, copilotAgent); + const session = await localService.createSession({ provider: 'copilot' }); + const sessionKey = session.toString(); + const internals = localService as unknown as { + _catalogSyncSuppressedSessions: Set; + _queueCatalogSync(session: URI, metadataOverrides: Readonly>): void; + _persistOrderedListVisibleSessionState(session: URI, metadataOverrides: Readonly>): Promise; + }; + internals._catalogSyncSuppressedSessions.add(sessionKey); + internals._queueCatalogSync(session, { + [SESSION_CUSTOM_TITLE_KEY]: 'Deferred rename', + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + [SESSION_ARTIFACTS_KEY]: '[]', + }); + + await internals._persistOrderedListVisibleSessionState(session, {}); + internals._catalogSyncSuppressedSessions.delete(sessionKey); + + assert.deepStrictEqual({ + title: await db.getMetadata(SESSION_CUSTOM_TITLE_KEY), + titleSource: await db.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + artifacts: await db.getMetadata(SESSION_ARTIFACTS_KEY), + }, { + title: 'Deferred rename', + titleSource: 'user', + artifacts: '[]', + }); + }); + test('a read/archive toggle on an un-loaded session persists and publishes without restoring it', async () => { // Regression: routing these toggles through `restoreSession` lost the // archived state whenever that restore failed. @@ -8636,8 +12203,9 @@ suite('AgentService (node dispatcher)', () => { const listener = localService.onDidNotification(n => notifications.push(n)); localService.dispatchAction(sessionStr, action, 'test-client', 1, AgentHostClientType.EditorWindow); - await timeout(0); - await timeout(0); + for (let attempt = 0; attempt < 20 && !notifications.some(notification => notification.type === 'root/sessionSummaryChanged'); attempt++) { + await timeout(0); + } listener.dispose(); const summaryChanged = notifications.find(n => n.type === 'root/sessionSummaryChanged'); @@ -8802,20 +12370,28 @@ suite('AgentService (node dispatcher)', () => { const notifications: INotification[] = []; const listener = localService.onDidNotification(n => notifications.push(n)); localService.dispatchAction(sessionStr, { type: ActionType.SessionIsArchivedChanged, isArchived: true }, 'test-client', 1, AgentHostClientType.EditorWindow); + localService.dispatchAction(sessionStr, { type: ActionType.SessionIsReadChanged, isRead: true }, 'other-client', 1, AgentHostClientType.EditorWindow); for (let i = 0; i < 20; i++) { await timeout(0); } listener.dispose(); const summaryChanged = notifications.find(n => n.type === 'root/sessionSummaryChanged'); + const listed = (await localService.listSessions()).find(session => session.session.toString() === sessionStr); assert.deepStrictEqual({ persisted: await db.getMetadata(AH_META_IS_ARCHIVED_DB_KEY), + persistedRead: await db.getMetadata(AH_META_IS_READ_DB_KEY), publishedArchived: summaryChanged?.type === 'root/sessionSummaryChanged' ? !!((summaryChanged.changes.status ?? 0) & SessionStatus.IsArchived) : undefined, + centralArchived: !!((listed?.status ?? 0) & SessionStatus.IsArchived), + centralRead: !!((listed?.status ?? 0) & SessionStatus.IsRead), }, { persisted: 'true', + persistedRead: 'true', publishedArchived: true, + centralArchived: true, + centralRead: true, }); }); @@ -9672,6 +13248,27 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('creating the same chat twice keeps a single catalog membership entry', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI): Promise { } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + + const chatUri = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, chatUri, { title: 'Peer Chat' }); + await localService.createChat(session, chatUri, { title: 'Peer Chat' }); + + const state = getStateManager(localService).getSessionState(session.toString()); + assert.deepStrictEqual( + (state?.chats ?? []).map(c => c.resource.toString()), + [buildDefaultChatUri(session), chatUri.toString()], + ); + }); + test('creates the backing chat before registering the chat in the catalog', async () => { let catalogHadChatDuringCreate: boolean | undefined; class MultiChatAgent extends MockAgent { @@ -9990,6 +13587,54 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('keeps a persisted backing suppressed until its central catalog acknowledges backing state', async () => { + const orchestratorDatabase = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const backingSession = AgentSession.uri('copilot', 'pending-central-backing'); + await seedVerifiedSessionV2(orchestratorDatabase, perSession.database(backingSession), backingSession, true, false); + class BackedChatAgent extends MockAgent { + override async createChat(): Promise { + return { providerData: 'blob', backingSession }; + } + } + const svc = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + perSession.service, + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + orchestratorDatabase, + )); + const agent = disposables.add(new BackedChatAgent('copilot')); + registerTestAgentProvider(svc, agent); + await svc.whenCatalogReconciliationIdle(); + const session = await svc.createSession({ provider: 'copilot' }); + const reconciliation = (svc as unknown as { _catalogReconciliationService: { schedule(): void } })._catalogReconciliationService; + reconciliation.schedule = () => { }; + + await svc.createChat(session, URI.parse(buildChatUri(session, 'peer-pending-central'))); + const centralBeforeRepair = await orchestratorDatabase.getSessionV2(backingSession.toString()); + const listedBeforeRepair = await svc.listSessions(); + + assert.deepStrictEqual({ + centralStillTopLevel: centralBeforeRepair?.isChatBacking, + listed: listedBeforeRepair.some(metadata => metadata.session.toString() === backingSession.toString()), + localMarker: await perSession.database(backingSession).getMetadata('peerChatBacking'), + }, { + centralStillTopLevel: false, + listed: false, + localMarker: buildChatUri(session, 'peer-pending-central'), + }); + }); + test('createSession carries client-owned _meta slots and drops unknown ones', async () => { const perSession = createPerSessionDataService(); const agent = disposables.add(new MockAgent('copilot')); @@ -10308,17 +13953,75 @@ suite('AgentService (node dispatcher)', () => { const state = getStateManager(service).getChatState(chatUri.toString()); assert.deepStrictEqual({ - origin: state?.origin, - copiedTurns: state?.turns.length, - forkForwarded: agent.lastCreateOptions?.fork && { - source: agent.lastCreateOptions.fork.source.toString(), - turnId: agent.lastCreateOptions.fork.turnId, - independentQueue: agent.lastCreateOptions.fork.independentQueue, - }, + origin: state?.origin, + copiedTurns: state?.turns.length, + forkForwarded: agent.lastCreateOptions?.fork && { + source: agent.lastCreateOptions.fork.source.toString(), + turnId: agent.lastCreateOptions.fork.turnId, + independentQueue: agent.lastCreateOptions.fork.independentQueue, + }, + }, { + origin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1', selection }, + copiedTurns: 0, + forkForwarded: { source: defaultChatUri, turnId: 't1', independentQueue: true }, + }); + }); + + test('creates a side chat with a selection larger than the catalog JSON string bound', async () => { + const sessionData = createPerSessionDataService(); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + sessionData.service, + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); + const agent = disposables.add(new SideChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + getStateManager(localService).seedDefaultChatTurns(session.toString(), [completedTurn('t1')]); + const chatUri = URI.parse(buildChatUri(session, 'large-selection')); + const defaultChatUri = buildDefaultChatUri(session); + const selectionText = 'selected text '.repeat(400); + assert.ok(selectionText.length > AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT); + + await localService.createChat(session, chatUri, { + sideChat: { + source: session, + turnId: 't1', + selection: { text: selectionText, responsePartId: 'response-part-1' }, + }, + }); + + const legacyPeers = JSON.parse((await sessionData.database(session).getMetadata('peerChats')) ?? '[]') as { uri: string; origin?: { selection?: { text?: string } } }[]; + const peerCatalog = await catalogDatabase.getSessionChatCatalog(session.toString()); + const peerCatalogOrigin = JSON.parse(peerCatalog?.chats.find(chat => chat.chat === chatUri.toString())?.origin ?? '{}') as { selection?: { text?: string } }; + const chatOrigin = JSON.parse((await sessionData.database(chatUri).getMetadata(CHAT_ORIGIN_METADATA_KEY)) ?? '{}') as { selection?: { text?: string } }; + const central = catalogDataOf(await catalogDatabase.getSessionV2(session.toString())); + const liveOrigin = getStateManager(localService).getChatState(chatUri.toString())?.origin; + + assert.deepStrictEqual({ + liveSelectionMatches: liveOrigin?.kind === ChatOriginKind.SideChat && liveOrigin.selection?.text === selectionText, + legacySelectionMatches: legacyPeers.find(peer => peer.uri === chatUri.toString())?.origin?.selection?.text === selectionText, + peerCatalogSelectionMatches: peerCatalogOrigin.selection?.text === selectionText, + chatMetadataSelectionMatches: chatOrigin.selection?.text === selectionText, + centralOrigin: central?.chats.find(chat => chat.uri === chatUri.toString())?.origin, }, { - origin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1', selection }, - copiedTurns: 0, - forkForwarded: { source: defaultChatUri, turnId: 't1', independentQueue: true }, + liveSelectionMatches: true, + legacySelectionMatches: true, + peerCatalogSelectionMatches: true, + chatMetadataSelectionMatches: true, + centralOrigin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1' }, }); }); @@ -11411,43 +15114,599 @@ suite('AgentService (node dispatcher)', () => { action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-sub', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded }, }); - const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + + // The tool call is denied/cancelled before the SDK ever + // confirms subagent_started — the resource never registers. + const subscribePromise = service.subscribe(URI.parse(subagentUri), 'client-race'); + await assert.rejects(subscribePromise, /Cannot subscribe to unknown resource/); + }); + }); + }); + + const createSingleDatabaseSessionDataService = createSessionDataService; + + // ---- peer-chat catalog persistence (B2: orchestrator-owned) --------- + + suite('peer chat catalog persistence', () => { + + function createSessionDataService(sessionDatabase: TestSessionDatabase = new TestSessionDatabase()): ISessionDataService { + const base = createSingleDatabaseSessionDataService(sessionDatabase); + const chatDatabases = new Map(); + const reference = (database: TestSessionDatabase): IReference => ({ + object: database, + dispose: () => { }, + }); + return { + ...base, + openDatabase: resource => { + if (!resource.authority) { + return reference(sessionDatabase); + } + let database = chatDatabases.get(resource.toString()); + if (!database) { + database = new TestSessionDatabase(); + chatDatabases.set(resource.toString(), database); + } + return reference(database); + }, + tryOpenDatabase: async resource => { + if (!resource.authority) { + return reference(sessionDatabase); + } + const database = chatDatabases.get(resource.toString()); + return database ? reference(database) : undefined; + }, + deleteSessionData: async resource => { + if (resource.authority) { + chatDatabases.delete(resource.toString()); + } + }, + }; + } + + /** Polls the persisted peer-chat catalog blob until it appears or times out. */ + async function readCatalog(db: TestSessionDatabase): Promise<{ uri: string; providerData?: string }[]> { + for (let i = 0; i < 50; i++) { + const raw = await db.getMetadata('peerChats'); + if (raw !== undefined) { + return JSON.parse(raw); + } + await timeout(0); + } + return []; + } + + async function waitForMetadata(db: TestSessionDatabase, key: string, expected: string): Promise { + for (let i = 0; i < 50; i++) { + if (await db.getMetadata(key) === expected) { + return; + } + await timeout(0); + } + assert.fail(`Metadata '${key}' did not become '${expected}'`); + } + + test('create and delete publish complete central chat sets while retaining downgrade metadata', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { + return { providerData: 'peer-backing' }; + } + } + const db = new TestSessionDatabase(); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); + const agent = disposables.add(new MultiChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const peer = URI.parse(buildChatUri(session, 'central-peer')); + + await localService.createChat(session, peer, { title: 'Central Peer' }); + const afterCreate = await catalogDatabase.getSessionV2(session.toString()); + const stateTitleAfterCreate = getStateManager(localService).getSessionState(session.toString())?.chats.find(chat => chat.resource === peer.toString())?.title; + const legacyTitleAfterCreate = await db.getMetadata(`customChatTitle:${peer.toString()}`); + await db.setChatDraft(peer, { text: 'delete me', origin: { kind: MessageKind.User } }); + await localService.disposeChat(session, peer); + const afterDelete = await catalogDatabase.getSessionV2(session.toString()); + + assert.deepStrictEqual({ + afterCreate: catalogDataOf(afterCreate)?.chats.map(chat => ({ uri: chat.uri, order: chat.order, kind: chat.kind, title: chat.summary })), + afterDelete: catalogDataOf(afterDelete)?.chats.map(chat => ({ uri: chat.uri, order: chat.order, kind: chat.kind })), + legacy: await readCatalog(db), + stateTitleAfterCreate, + legacyTitleAfterCreate, + draft: await db.getChatDraft(peer), + }, { + afterCreate: [ + { uri: buildDefaultChatUri(session), order: 0, kind: 'default', title: undefined }, + { uri: peer.toString(), order: 1, kind: 'peer', title: 'Central Peer' }, + ], + afterDelete: [ + { uri: buildDefaultChatUri(session), order: 0, kind: 'default' }, + ], + legacy: [], + stateTitleAfterCreate: 'Central Peer', + legacyTitleAfterCreate: 'Central Peer', + draft: undefined, + }); + }); + + test('concurrent chat creation preserves every central payload membership', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { + return {}; + } + } + const db = new TestSessionDatabase(); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, + )); + registerTestAgentProvider(localService, disposables.add(new MultiChatAgent('copilot'))); + const session = await localService.createSession({ provider: 'copilot' }); + const first = URI.parse(buildChatUri(session, 'concurrent-first')); + const second = URI.parse(buildChatUri(session, 'concurrent-second')); + + await Promise.all([ + localService.createChat(session, first, { title: 'First' }), + localService.createChat(session, second, { title: 'Second' }), + ]); + + assert.deepStrictEqual( + catalogDataOf(await catalogDatabase.getSessionV2(session.toString()))?.chats.map(chat => chat.uri), + [buildDefaultChatUri(session), first.toString(), second.toString()], + ); + }); + + test('concurrent chat removal removes every disposed membership from the central payload', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { + return {}; + } + override async disposeChat(): Promise { } + } + const db = new TestSessionDatabase(); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, + )); + registerTestAgentProvider(localService, disposables.add(new MultiChatAgent('copilot'))); + const session = await localService.createSession({ provider: 'copilot' }); + const first = URI.parse(buildChatUri(session, 'concurrent-first')); + const second = URI.parse(buildChatUri(session, 'concurrent-second')); + await localService.createChat(session, first); + await localService.createChat(session, second); + + await Promise.all([ + localService.disposeChat(session, first), + localService.disposeChat(session, second), + ]); + + assert.deepStrictEqual( + catalogDataOf(await catalogDatabase.getSessionV2(session.toString()))?.chats.map(chat => chat.uri), + [buildDefaultChatUri(session)], + ); + }); + + test('a dispose requested during provider creation runs after the chat is published', async () => { + const createStarted = new DeferredPromise(); + const releaseCreate = new DeferredPromise(); + class GatedCreateAgent extends MockAgent { + override async createChat(): Promise { + createStarted.complete(); + await releaseCreate.p; + return {}; + } + override async disposeChat(): Promise { } + } + const db = new TestSessionDatabase(); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, + )); + registerTestAgentProvider(localService, disposables.add(new GatedCreateAgent('copilot'))); + const session = await localService.createSession({ provider: 'copilot' }); + const chat = URI.parse(buildChatUri(session, 'create-dispose-race')); + + const create = localService.createChat(session, chat); + await createStarted.p; + const dispose = localService.disposeChat(session, chat); + releaseCreate.complete(); + await Promise.all([create, dispose]); + + assert.deepStrictEqual( + catalogDataOf(await catalogDatabase.getSessionV2(session.toString()))?.chats.map(candidate => candidate.uri), + [buildDefaultChatUri(session)], + ); + }); + + test('restart restores central peer membership without legacy enumeration and loads backing lazily', async () => { + class CountingDatabase extends TestSessionDatabase { + peerCatalogReads = 0; + + override async getMetadata(key: string): Promise { + if (key === 'peerChats') { + this.peerCatalogReads++; + } + return super.getMetadata(key); + } + } + class MultiChatAgent extends MockAgent { + legacyEnumerations = 0; + peerMaterializations = 0; + + override async createChat(): Promise { + return { providerData: 'lazy-backing' }; + } + + async listLegacyChatBackings(): Promise { + this.legacyEnumerations++; + return []; + } + + override async materializeChat(chat: URI): Promise { + if (!isDefaultChatUri(chat)) { + this.peerMaterializations++; + } + } + } + const db = new CountingDatabase(); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); + const agent = disposables.add(new MultiChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const peer = URI.parse(buildChatUri(session, 'lazy-central-peer')); + await localService.createChat(session, peer, { title: 'Lazy Central Peer' }); + db.peerCatalogReads = 0; + + getStateManager(localService).deleteSession(session.toString()); + await localService.restoreSession(session); + const restored = getStateManager(localService).getSessionState(session.toString())?.chats.map(chat => ({ + uri: chat.resource, + title: chat.title, + })); + const beforeAccess = { + peerCatalogReads: db.peerCatalogReads, + legacyEnumerations: agent.legacyEnumerations, + peerMaterializations: agent.peerMaterializations, + }; + await localService.subscribe(peer, 'lazy-central-reader'); + + assert.deepStrictEqual({ + restored, + beforeAccess, + afterAccess: { + peerCatalogReads: db.peerCatalogReads, + legacyEnumerations: agent.legacyEnumerations, + peerMaterializations: agent.peerMaterializations, + }, + }, { + restored: [ + { uri: buildDefaultChatUri(session), title: 'Session' }, + { uri: peer.toString(), title: 'Lazy Central Peer' }, + ], + beforeAccess: { + peerCatalogReads: 1, + legacyEnumerations: 0, + peerMaterializations: 0, + }, + afterAccess: { + peerCatalogReads: 1, + legacyEnumerations: 0, + peerMaterializations: 1, + }, + }); + }); + + test('authoritative peer recovery does not read the cached catalog payload', async () => { + class CountingCatalogDatabase extends AgentHostDatabase { + payloadReads = 0; + + override async getSessionV2(session: string) { + this.payloadReads++; + return super.getSessionV2(session); + } + } + const db = new TestSessionDatabase(); + const catalogDatabase = disposables.add(new CountingCatalogDatabase(':memory:')); + const session = AgentSession.uri('copilot', 'authoritative-peer'); + const peer = buildChatUri(session, 'peer'); + await catalogDatabase.registerRuntimeSession(session.toString(), { + provider: 'copilot', + startTime: 1, + source: 'restore', + }, { checkTombstone: false }); + const replacement = await catalogDatabase.replaceSessionChatCatalog(session.toString(), [{ chat: peer, order: 0 }], undefined); + assert.strictEqual(replacement.status, 'applied'); + if (replacement.status === 'applied') { + await catalogDatabase.markSessionChatCatalogLegacyMirrored(session.toString(), replacement.revision, JSON.stringify([{ uri: peer }])); + } + const localService = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, + )); + const agent = disposables.add(new MockAgent('copilot')); + + const peers = await (localService as unknown as { + _readOrMigrateLegacyPeerChatCatalog(agent: IAgent, session: URI): Promise; + })._readOrMigrateLegacyPeerChatCatalog(agent, session); + + assert.deepStrictEqual({ + peers, + payloadReads: catalogDatabase.payloadReads, + }, { + peers: [{ uri: peer }], + payloadReads: 0, + }); + }); + + test('lossy cached peer recovery preserves provider backing data from legacy enumeration', async () => { + class LossyFallbackDatabase extends AgentHostDatabase { + hiddenCatalogReads = 0; + + override async getSessionChatCatalog(session: string): Promise { + if (this.hiddenCatalogReads > 0) { + this.hiddenCatalogReads--; + return undefined; + } + return super.getSessionChatCatalog(session); + } + } + class LegacyBackingAgent extends MockAgent { + legacyEnumerations = 0; + readonly materializedProviderData: Array = []; + + override async createChat(): Promise { + return { providerData: 'provider-backing' }; + } + + async listLegacyChatBackings(session: URI): Promise { + this.legacyEnumerations++; + return [ + { uri: URI.parse(buildChatUri(session, 'stale-legacy-peer')), providerData: 'stale-backing' }, + { uri: URI.parse(buildChatUri(session, 'cached-peer')), providerData: 'provider-backing' }, + ]; + } + + override async materializeChat(chat: URI, _context: URI | IAgentChatContext, providerData: string | undefined): Promise { + if (!isDefaultChatUri(chat)) { + this.materializedProviderData.push(providerData); + } + } + } + class HiddenLegacyCatalogDatabase extends TestSessionDatabase { + hideLegacyCatalog = true; + + override async getMetadata(key: string): Promise { + return this.hideLegacyCatalog && key === 'peerChats' ? undefined : super.getMetadata(key); + } + } + const db = new HiddenLegacyCatalogDatabase(); + const sessionDataService = createSessionDataService(db); + const catalogDatabase = disposables.add(new LossyFallbackDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), fileService, sessionDataService, + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, + )); + const agent = disposables.add(new LegacyBackingAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const peer = URI.parse(buildChatUri(session, 'cached-peer')); + await localService.createChat(session, peer, { title: 'Cached Peer' }); + await sessionDataService.deleteSessionData(peer); + + getStateManager(localService).deleteSession(session.toString()); + catalogDatabase.hiddenCatalogReads = 2; + await localService.restoreSession(session); + await localService.subscribe(peer, 'cached-peer-reader'); + db.hideLegacyCatalog = false; + + assert.deepStrictEqual({ + legacyEnumerations: agent.legacyEnumerations, + materializedProviderData: agent.materializedProviderData, + persistedProviderData: (await readCatalog(db)).find(entry => entry.uri === peer.toString())?.providerData, + persistedPeers: (await readCatalog(db)).map(entry => entry.uri), + }, { + legacyEnumerations: 1, + materializedProviderData: ['provider-backing'], + persistedProviderData: 'provider-backing', + persistedPeers: [peer.toString()], + }); + }); + + test('restart replaces stale central peer membership with the cooling-period legacy catalog', async () => { + class MultiChatAgent extends MockAgent { + legacyEnumerations = 0; + readonly materialized: string[] = []; + + override async createChat(): Promise { + return { providerData: 'central-backing' }; + } + + async listLegacyChatBackings(): Promise { + this.legacyEnumerations++; + return []; + } + + override async materializeChat(chat: URI): Promise { + this.materialized.push(chat.toString()); + } + } + const db = new TestSessionDatabase(); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); + const agent = disposables.add(new MultiChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const centralPeer = URI.parse(buildChatUri(session, 'central-peer')); + const legacyPeer = URI.parse(buildChatUri(session, 'newer-legacy-peer')); + await localService.createChat(session, centralPeer, { title: 'Central Peer' }); + await db.setMetadata('peerChats', JSON.stringify([ + { uri: legacyPeer.toString(), providerData: 'legacy-backing' }, + ])); + + getStateManager(localService).deleteSession(session.toString()); + await localService.restoreSession(session); + const firstRestore = getStateManager(localService).getSessionState(session.toString())?.chats.map(chat => chat.resource); + const repairedCentral = await catalogDatabase.getSessionV2(session.toString()); + await localService.subscribe(legacyPeer, 'legacy-reader'); + getStateManager(localService).deleteSession(session.toString()); + await localService.restoreSession(session); - // The tool call is denied/cancelled before the SDK ever - // confirms subagent_started — the resource never registers. - const subscribePromise = service.subscribe(URI.parse(subagentUri), 'client-race'); - await assert.rejects(subscribePromise, /Cannot subscribe to unknown resource/); + assert.deepStrictEqual({ + firstRestore, + repairedCentral: catalogDataOf(repairedCentral)?.chats.map(chat => chat.uri), + secondRestore: getStateManager(localService).getSessionState(session.toString())?.chats.map(chat => chat.resource), + legacyEnumerations: agent.legacyEnumerations, + materialized: agent.materialized.filter(chat => !isDefaultChatUri(URI.parse(chat))).sort(), + }, { + firstRestore: [buildDefaultChatUri(session), legacyPeer.toString()], + repairedCentral: [buildDefaultChatUri(session), legacyPeer.toString()], + secondRestore: [buildDefaultChatUri(session), legacyPeer.toString()], + legacyEnumerations: 0, + materialized: [legacyPeer.toString()], }); }); - }); - - // ---- peer-chat catalog persistence (B2: orchestrator-owned) --------- - suite('peer chat catalog persistence', () => { + test('failed peer creation removes chat-local title data before parent deletion', async () => { + class FailingCatalogSourceDatabase extends TestSessionDatabase { + failMetadataReads = false; - /** Polls the persisted peer-chat catalog blob until it appears or times out. */ - async function readCatalog(db: TestSessionDatabase): Promise<{ uri: string; providerData?: string }[]> { - for (let i = 0; i < 50; i++) { - const raw = await db.getMetadata('peerChats'); - if (raw !== undefined) { - return JSON.parse(raw); + override async getMetadataObject>(obj: T): Promise<{ [K in keyof T]: string | undefined }> { + if (this.failMetadataReads) { + throw new Error('catalog metadata read failed'); + } + return super.getMetadataObject(obj); } - await timeout(0); } - return []; - } + class MultiChatAgent extends MockAgent { + readonly disposedPeers: string[] = []; - async function waitForMetadata(db: TestSessionDatabase, key: string, expected: string): Promise { - for (let i = 0; i < 50; i++) { - if (await db.getMetadata(key) === expected) { - return; + override async createChat(): Promise { + return { providerData: 'failed-peer-backing' }; + } + + override async disposeChat(_session: URI, chat: URI): Promise { + this.disposedPeers.push(chat.toString()); } - await timeout(0); } - assert.fail(`Metadata '${key}' did not become '${expected}'`); - } + const sessionDatabase = new FailingCatalogSourceDatabase(); + const chatDatabases = new Map(); + const deletedChatDatabases = new Map(); + const deleted: string[] = []; + const base = createSingleDatabaseSessionDataService(sessionDatabase); + const reference = (database: TestSessionDatabase): IReference => ({ object: database, dispose: () => { } }); + const sessionDataService: ISessionDataService = { + ...base, + openDatabase: resource => { + if (!resource.authority) { + return reference(sessionDatabase); + } + let database = chatDatabases.get(resource.toString()); + if (!database) { + database = new TestSessionDatabase(); + chatDatabases.set(resource.toString(), database); + } + return reference(database); + }, + tryOpenDatabase: async resource => { + const database = resource.authority ? chatDatabases.get(resource.toString()) : sessionDatabase; + return database ? reference(database) : undefined; + }, + deleteSessionData: async resource => { + deleted.push(resource.toString()); + const database = chatDatabases.get(resource.toString()); + if (database) { + deletedChatDatabases.set(resource.toString(), database); + } + chatDatabases.delete(resource.toString()); + }, + }; + const localService = disposables.add(createTestAgentService( + new NullLogService(), fileService, sessionDataService, + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + )); + const agent = disposables.add(new MultiChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const peer = URI.parse(buildChatUri(session, 'failed-peer')); + sessionDatabase.failMetadataReads = true; + + await assert.rejects(localService.createChat(session, peer, { title: 'Temporary Title' }), /catalog metadata read failed/); + const deletedChatDatabase = deletedChatDatabases.get(peer.toString()); + sessionDatabase.failMetadataReads = false; + const orphan = await sessionDataService.tryOpenDatabase(peer); + orphan?.dispose(); + await localService.disposeSession(session); + + assert.deepStrictEqual({ + titleWasPersisted: await deletedChatDatabase?.getMetadata(SESSION_CUSTOM_TITLE_KEY), + orphanExists: orphan !== undefined, + disposedPeers: agent.disposedPeers, + deleted, + }, { + titleWasPersisted: 'Temporary Title', + orphanExists: false, + disposedPeers: [peer.toString(), buildDefaultChatUri(session)], + deleted: [peer.toString(), buildDefaultChatUri(session), session.toString()], + }); + }); - test('rolls back a new peer chat when its catalog entry cannot be persisted', async () => { + test('keeps a new peer chat when its downgrade mirror cannot be persisted', async () => { class FailingPeerCatalogDatabase extends TestSessionDatabase { failPeerCatalogWrites = false; @@ -11476,14 +15735,16 @@ suite('AgentService (node dispatcher)', () => { const peer = URI.parse(buildChatUri(session, 'unpersisted-peer')); db.failPeerCatalogWrites = true; - await assert.rejects(() => localService.createChat(session, peer), /peer catalog write failed/); + await localService.createChat(session, peer); assert.deepStrictEqual({ chats: getStateManager(localService).getSessionState(session.toString())?.chats.map(chat => chat.resource.toString()), disposed: agent.disposedPeers.map(call => call.toString()), + legacy: await db.getMetadata('peerChats'), }, { - chats: [buildDefaultChatUri(session)], - disposed: [peer.toString()], + chats: [buildDefaultChatUri(session), peer.toString()], + disposed: [], + legacy: undefined, }); }); @@ -12599,7 +16860,116 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('disposeChat preserves the chat when catalog removal fails so deletion can be retried', async () => { + test('disposeChat removes live contribution state after authoritative removal when ancillary cleanup fails', async () => { + class FailingDraftCleanupDatabase extends TestSessionDatabase { + failDraftCleanup = false; + + override async setChatDraft(chat: URI, draft: Message | undefined): Promise { + if (this.failDraftCleanup && draft === undefined) { + throw new Error('draft cleanup failed'); + } + return super.setChatDraft(chat, draft); + } + } + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { + return { providerData: 'initial' }; + } + override async disposeChat(): Promise { } + } + const sessionDatabase = new FailingDraftCleanupDatabase(); + const orchestratorDatabase = new TransientRegistryWriteDatabase(); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(sessionDatabase), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + orchestratorDatabase, + )); + const agent = disposables.add(new MultiChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const peer = URI.parse(buildChatUri(session, 'peer-cleanup-failure')); + await localService.createChat(session, peer); + sessionDatabase.failDraftCleanup = true; + + await assert.rejects(() => localService.disposeChat(session, peer), /draft cleanup failed/); + + assert.deepStrictEqual({ + centralMembership: (await orchestratorDatabase.getSessionChatCatalog(session.toString()))?.chats.map(chat => chat.chat), + liveMembership: getStateManager(localService).getSessionState(session.toString())?.chats.map(chat => chat.resource).includes(peer.toString()), + payloadRepairScheduled: ((await orchestratorDatabase.getSessionV2(session.toString()))?.payloadDirty ?? 0) > 0, + }, { + centralMembership: [], + liveMembership: false, + payloadRepairScheduled: true, + }); + }); + + test('disposeChat removes live contribution state when catalog projection fails after data cleanup', async () => { + class FailingProjectionDatabase extends TestSessionDatabase { + failCatalogWrite = false; + + override async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + if (this.failCatalogWrite) { + throw new Error('catalog projection failed'); + } + return super.setMetadataValuesAndCatalogSyncSnapshot(values, snapshot); + } + } + class MultiChatAgent extends MockAgent { + override async createChat(): Promise { + return { providerData: 'initial' }; + } + override async disposeChat(): Promise { } + } + const sessionDatabase = new FailingProjectionDatabase(); + const orchestratorDatabase = new TransientRegistryWriteDatabase(); + const sessionDataService = createSessionDataService(sessionDatabase); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + sessionDataService, + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + orchestratorDatabase, + )); + const agent = disposables.add(new MultiChatAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + const peer = URI.parse(buildChatUri(session, 'peer-projection-failure')); + await localService.createChat(session, peer); + sessionDatabase.failCatalogWrite = true; + + await assert.rejects(() => localService.disposeChat(session, peer), /catalog projection failed/); + + assert.deepStrictEqual({ + centralMembership: (await orchestratorDatabase.getSessionChatCatalog(session.toString()))?.chats.map(chat => chat.chat), + liveMembership: getStateManager(localService).getSessionState(session.toString())?.chats.map(chat => chat.resource).includes(peer.toString()), + }, { + centralMembership: [], + liveMembership: false, + }); + }); + + test('disposeChat keeps central deletion when the downgrade mirror fails and repairs it later', async () => { class FailingRemovalDatabase extends TestSessionDatabase { failRemoval = false; override async setMetadata(key: string, value: string): Promise { @@ -12624,7 +16994,7 @@ suite('AgentService (node dispatcher)', () => { await localService.createChat(session, peer); db.failRemoval = true; - await assert.rejects(() => localService.disposeChat(session, peer), /catalog removal failed/); + await localService.disposeChat(session, peer); const retainedAfterFailure = getStateManager(localService).getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peer.toString()); db.failRemoval = false; await localService.disposeChat(session, peer); @@ -12634,7 +17004,7 @@ suite('AgentService (node dispatcher)', () => { catalog: await readCatalog(db), inMemoryAfterRetry: getStateManager(localService).getSessionState(session.toString())?.chats.some(chat => chat.resource.toString() === peer.toString()), }, { - retainedAfterFailure: true, + retainedAfterFailure: false, catalog: [], inMemoryAfterRetry: false, }); @@ -12739,6 +17109,49 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('malformed legacy peerChats data is rebuilt from provider enumeration', async () => { + class LegacyAgent extends MockAgent { + legacyEnumerations = 0; + + async listLegacyChatBackings(): Promise { + this.legacyEnumerations++; + return []; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + new TestAgentHostOrchestratorDatabase(), + )); + const agent = disposables.add(new LegacyAgent('copilot')); + registerTestAgentProvider(localService, agent); + const session = await localService.createSession({ provider: 'copilot' }); + await db.setMetadata('peerChats', '{"not":"an array"}'); + getStateManager(localService).deleteSession(session.toString()); + + await localService.restoreSession(session); + + assert.deepStrictEqual({ + legacyEnumerations: agent.legacyEnumerations, + repaired: await db.getMetadata('peerChats'), + }, { + legacyEnumerations: 1, + repaired: '[]', + }); + }); + test('a valid new-format peerChats catalog restores without consulting legacy chats', async () => { class LegacyAgent extends MockAgent { listLegacyCallCount = 0; @@ -12812,9 +17225,9 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('a rejected migration write leaves the catalog absent (not a subset) so migration re-runs', async () => { + test('a rejected migration mirror leaves central membership available and repairs on restore', async () => { class FailingCatalogDatabase extends TestSessionDatabase { - failPeerChatsWrites = 1; + failPeerChatsWrites = 0; override async setMetadata(key: string, value: string): Promise { if (key === 'peerChats' && this.failPeerChatsWrites > 0) { this.failPeerChatsWrites--; @@ -12834,19 +17247,34 @@ suite('AgentService (node dispatcher)', () => { } } const db = new FailingCatalogDatabase(); - const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + orchestratorDatabase, + )); const agent = disposables.add(new LegacyAgent('copilot')); registerTestAgentProvider(localService, agent); const session = await localService.createSession({ provider: 'copilot' }); - // First restore: the single catalog write is rejected. Because the write - // is all-or-nothing, the key must stay absent (never a proper subset). + // First restore commits central authority even though the cooling mirror fails. + db.failPeerChatsWrites = 1; getStateManager(localService).deleteSession(session.toString()); - await assert.rejects(() => localService.restoreSession(session), /simulated catalog write failure/); + await localService.restoreSession(session); const catalogAfterFailedWrite = await db.getMetadata('peerChats'); - // Second restore: catalog still absent => migration re-runs and now - // persists the complete set. + // Second restore repairs the unacknowledged compatibility mirror. getStateManager(localService).deleteSession(session.toString()); await localService.restoreSession(session); const catalog = await readCatalog(db); @@ -12873,6 +17301,14 @@ suite('AgentService (node dispatcher)', () => { await this.finalRenamePersisted.complete(); } } + + override async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + const result = await super.setMetadataValuesAndCatalogSyncSnapshot(values, snapshot); + if (this.finalRenameKey && values[this.finalRenameKey] === 'Complete replacement peer chat title') { + await this.finalRenamePersisted.complete(); + } + return result; + } } class ServerToolAgent extends MockAgent { serverToolHost: IAgentServerToolHost | undefined; @@ -12883,7 +17319,23 @@ suite('AgentService (node dispatcher)', () => { } const db = new RecordingTitleDatabase(); - const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(createTestAgentService( + new NullLogService(), + fileService, + createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); getConfigurationService(localService).updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: true }); const agent = disposables.add(new ServerToolAgent('copilot')); registerTestAgentProvider(localService, agent); @@ -12924,6 +17376,13 @@ suite('AgentService (node dispatcher)', () => { }); await db.finalRenamePersisted.p; const summaryTitleChange = await summaryTitleChanged.p; + await timeout(0); + const central = await catalogDatabase.getSessionV2(sessionUri); + const centralChats = catalogDataOf(central)?.chats.map(chat => ({ + uri: chat.uri, + title: chat.summary, + titleSource: chat.titleSource, + })); assert.deepStrictEqual({ singleChatResult, @@ -12938,6 +17397,7 @@ suite('AgentService (node dispatcher)', () => { persistedDefaultChatSource: await db.getMetadata(`customChatTitleSource:${defaultChat}`), persistedChatTitle: await db.getMetadata(`customChatTitle:${peerChat}`), persistedChatSource: await db.getMetadata(`customChatTitleSource:${peerChat}`), + centralChats, summaryTitleChange, }, { singleChatResult: 'Renamed chat to "Single-chat title".', @@ -12952,6 +17412,10 @@ suite('AgentService (node dispatcher)', () => { persistedDefaultChatSource: 'agent', persistedChatTitle: 'Complete replacement peer chat title', persistedChatSource: 'agent', + centralChats: [ + { uri: defaultChat, title: 'Complete replacement default chat title', titleSource: 'agent' }, + { uri: peerChat, title: 'Complete replacement peer chat title', titleSource: 'agent' }, + ], summaryTitleChange: 'Complete replacement default chat title', }); }); @@ -12961,15 +17425,24 @@ suite('AgentService (node dispatcher)', () => { readonly allFailuresObserved = new DeferredPromise(); private failureCount = 0; - override async setMetadataValues(values: Readonly>): Promise { + private async failTitleWrite(values: Readonly>): Promise { if (Object.keys(values).some(key => key.startsWith('customTitle') || key.startsWith('customChatTitle'))) { if (++this.failureCount === 3) { await this.allFailuresObserved.complete(); } throw new Error('title persistence failed'); } + } + + override async setMetadataValues(values: Readonly>): Promise { + await this.failTitleWrite(values); return super.setMetadataValues(values); } + + override async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + await this.failTitleWrite(values); + return super.setMetadataValuesAndCatalogSyncSnapshot(values, snapshot); + } } class ServerToolAgent extends MockAgent { serverToolHost: IAgentServerToolHost | undefined; @@ -14115,13 +18588,7 @@ suite('AgentService (node dispatcher)', () => { const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); registerTestAgentProvider(localService, localAgent); - await localService.createSession({ - provider: 'copilot', - config: { - autoApprove: 'autoApprove', - [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export TRANSIENT=1' }], - }, - }); + await localService.createSession({ provider: 'copilot', config: { autoApprove: 'autoApprove' } }); // Persistence is fire-and-forget; wait for it to flush await new Promise(r => setTimeout(r, 50)); @@ -14168,10 +18635,7 @@ suite('AgentService (node dispatcher)', () => { ))); registerTestAgentProvider(localService, localAgent); - await sessionDb.setMetadata('configValues', JSON.stringify({ - autoApprove: 'autoApprove', - [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export STALE=1' }], - })); + await sessionDb.setMetadata('configValues', JSON.stringify({ autoApprove: 'autoApprove' })); const { session } = await createAgentSession(localAgent); localAgent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, @@ -14184,11 +18648,9 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ isolation: values?.[SessionConfigKey.Isolation], autoApprove: values?.autoApprove, - shellInitScripts: values?.[SessionConfigKey.ShellInitScripts], }, { isolation: 'folder', autoApprove: 'autoApprove', - shellInitScripts: undefined, }); }); @@ -14366,16 +18828,22 @@ suite('AgentService (node dispatcher)', () => { const session = await localService.createSession({ provider: 'copilot', - config: { autoApprove: 'autoApprove' }, + config: { + autoApprove: 'autoApprove', + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export TRANSIENT=1' }], + }, _meta: { 'vscode.devContainerWorktree': { version: 1, handle: '00000000-0000-4000-8000-000000000001' } }, }); // Wait for the fire-and-forget persistence to flush await new Promise(r => setTimeout(r, 50)); - const listed = await localService.listSessions(); - - // Simulate a server restart: drop the in-memory state + const persistedConfigValues = JSON.parse((await sessionDb.getMetadata('configValues'))!); + await sessionDb.setMetadata('configValues', JSON.stringify({ + autoApprove: 'autoApprove', + [SessionConfigKey.ShellInitScripts]: [{ shell: 'bash', script: 'export STALE=1' }], + })); getStateManager(localService).removeSession(session.toString()); + const listed = await localService.listSessions(); localAgent.sessionMessages = [ { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, @@ -14386,10 +18854,12 @@ suite('AgentService (node dispatcher)', () => { const state = getStateManager(localService).getSessionState(session.toString()); assert.ok(state); assert.deepStrictEqual({ + persistedConfigValues, config: state!.config?.values, listedDevContainerWorktree: listed[0]?._meta?.['vscode.devContainerWorktree'], devContainerWorktree: state!._meta?.['vscode.devContainerWorktree'], }, { + persistedConfigValues: { autoApprove: 'autoApprove' }, config: { autoApprove: 'autoApprove' }, listedDevContainerWorktree: { version: 1, handle: '00000000-0000-4000-8000-000000000001' }, devContainerWorktree: { version: 1, handle: '00000000-0000-4000-8000-000000000001' }, @@ -15170,8 +19640,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ // The turn exists only to carry the notice, so its own message // stays out of the transcript. - hiddenTurn: isMessageHiddenFromTranscript(notice.message), - hiddenRequest: isMessageRequestHiddenFromTranscript(notice.message), + hiddenMessage: isMessageRequestHiddenFromTranscript(notice.message), origin: notice.message.origin.kind, state: notice.state, responseParts: notice.responseParts, @@ -15182,8 +19651,7 @@ suite('AgentService (node dispatcher)', () => { // so it only survives reload as a local turn. persistedLocally: (await sessionDb.getLocalTurns()).map(record => ({ chatUri: record.chatUri, turnId: record.turnId })), }, { - hiddenTurn: false, - hiddenRequest: true, + hiddenMessage: true, origin: MessageKind.SystemNotification, state: TurnState.Complete, responseParts: [{ diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index 95bfea811c8efa..7946b9bfc9c44d 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -24,6 +24,7 @@ import { IAgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from '../../node/agentHostFileMonitorService.js'; import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { AgentService } from '../../node/agentService.js'; +import type { IAgentHostCatalogReconciliationOptions } from '../../node/agentHostCatalogReconciliationService.js'; import { createAgentServiceComposition, type IAgentServiceComposition } from '../../node/agentServiceComposition.js'; import { activateAgentHostContributions } from '../../node/agentHostContributions.js'; import { createAgentServiceFoundation } from '../../node/agentServiceFoundation.js'; @@ -146,6 +147,7 @@ export function createTestAgentService( orchestratorDatabase?: IAgentHostDatabase, sessionResidencyLimit?: number, sessionReleaseRetryMs?: number, + catalogReconciliationOptions?: IAgentHostCatalogReconciliationOptions, ): AgentService { const effectiveFileMonitorService = fileMonitorService ?? new AgentHostFileMonitorService(fileService, logService); const clientConnectionService = new AgentHostClientConnectionService(); @@ -170,6 +172,7 @@ export function createTestAgentService( orchestratorDatabase, sessionResidencyLimit, sessionReleaseRetryMs, + catalogReconciliationOptions, }; const foundation = createAgentServiceFoundation({ services, @@ -196,6 +199,8 @@ export function createTestAgentService( const effectiveCopilotApiService = instantiationService.invokeFunction(accessor => accessor.get(ICopilotApiService)); services.set(IAgentHostSessionTitleController, foundationDisposables.add(instantiationService.createInstance(AgentHostSessionTitleController, foundation.stateManager, { sessionDataService, + queueCatalogSync: (session, metadataOverrides) => foundation.callbackAdapter.value.queueCatalogSync(session, metadataOverrides), + persistSurfacedSessionTitle: (session, title) => foundation.callbackAdapter.value.persistSurfacedSessionTitle(session, title), getGitHubCopilotToken: () => { const resource = foundation.gitHubEndpointService.getCopilotResource(); return foundation.authenticationService.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }); diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index 6cf1b9e13d61bd..ebaed3cbea7826 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agent.js'; -import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionChatCatalogReplaceResult, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry } from '../../node/agentSessionRegistry.js'; class TestAgentHostDatabase implements IAgentHostDatabase { @@ -15,6 +15,8 @@ class TestAgentHostDatabase implements IAgentHostDatabase { readonly agentMergeEnabled = new Set(); backfilled = false; private readonly _providerBackfilled = new Set(); + private readonly _sessionsV2Backfilled = new Set(); + private readonly _sessionsV2Exclusions = new Map(); private readonly _tombstones = new Set(); private _writeFailures = 0; private _readFailures = 0; @@ -130,6 +132,43 @@ class TestAgentHostDatabase implements IAgentHostDatabase { this._providerBackfilled.add(provider); } + async isSessionsV2Backfilled(provider: string, projectionVersion: number): Promise { + this._throwReadFailure(); + return this._sessionsV2Backfilled.has(`${provider}:${projectionVersion}`); + } + + async markSessionsV2Backfilled(provider: string, projectionVersion: number): Promise { + this._throwWriteFailure(); + this._sessionsV2Backfilled.add(`${provider}:${projectionVersion}`); + } + + async markSessionsV2Excluded(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise { + this._throwWriteFailure(); + this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); + } + + async excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise<'excluded'> { + this._throwWriteFailure(); + this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); + this.sessions.delete(exclusion.session); + return 'excluded'; + } + + async getSessionsV2Exclusion(provider: string, session: string): Promise { + this._throwReadFailure(); + return this._sessionsV2Exclusions.get(`${provider}:${session}`); + } + + async listSessionsV2Exclusions(provider: string): Promise { + this._throwReadFailure(); + return [...this._sessionsV2Exclusions.values()].filter(exclusion => exclusion.provider === provider); + } + + async clearSessionsV2Exclusion(provider: string, session: string): Promise { + this._throwWriteFailure(); + this._sessionsV2Exclusions.delete(`${provider}:${session}`); + } + async isSessionTombstoned(session: string): Promise { this._throwReadFailure(); return this._tombstones.has(session); @@ -145,6 +184,22 @@ class TestAgentHostDatabase implements IAgentHostDatabase { this._tombstones.delete(session); } + async registerRuntimeSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + return this.registerSessionV2(session, sessionOptions, registerOptions); + } + + unregisterRuntimeSession(session: string): Promise { + return this.unregisterSessionV2(session); + } + + updateRuntimeSessionExternal(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise { + return this.updateSessionV2External(updates); + } + + async listRuntimeCompatibleSessionKeys(): Promise { + return [...this.sessions.keys()]; + } + async setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise { this._throwWriteFailure(); if (enabled) { @@ -159,6 +214,56 @@ class TestAgentHostDatabase implements IAgentHostDatabase { return [...this.agentMergeEnabled]; } + async registerSessionV2(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + const registered = await this.registerSession(session, sessionOptions, registerOptions); + if (registered) { + this._sessionsV2Exclusions.delete(`${sessionOptions.provider}:${session}`); + } + return registered; + } + + unregisterSessionV2(session: string): Promise { + return this.unregisterSession(session); + } + + updateSessionV2External(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise { + return this.updateSessionExternal(updates); + } + + async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { + this.sessions.set(session, legacy); + return legacy; + } + + getSessionV2Registration(session: string): Promise { + return this.getSession(session); + } + + listSessionV2Registrations(): Promise { + return this.listSessions(); + } + + listSessionV2RegistrationsForImport(): Promise { + return this.listSessionV2Registrations(); + } + + isSessionV2RegistryEmpty(): Promise { + return this.isSessionRegistryEmpty(); + } + + async getSessionV2(): Promise { return undefined; } + async listSessionsV2(): Promise { return []; } + async listSessionsV2Receipts(): Promise { return []; } + async markSessionV2PayloadDirty(): Promise { return undefined; } + async getSessionV2PayloadDirty(): Promise { return undefined; } + async markAllSessionsV2PayloadsDirty(): Promise { } + async markSessionV2PayloadClean(): Promise { return false; } + async upsertSessionV2(_envelope: IAgentHostDatabaseSessionV2Envelope, _expectedSessionGeneration: string | undefined): Promise { return 'missingSession'; } + async getSessionChatCatalog(_session: string): Promise { return undefined; } + async replaceSessionChatCatalog(_session: string, _chats: readonly IAgentHostDatabaseSessionChat[], _expectedRevision: number | undefined): Promise { return { status: 'applied', revision: 1 }; } + async markSessionChatCatalogLegacyMirrored(_session: string, _expectedRevision: number): Promise { return false; } + async recordSessionChatCatalogLegacyMirrorPayload(_session: string, _expectedRevision: number, _payload: string): Promise { return false; } + async close(): Promise { } dispose(): void { } @@ -221,6 +326,21 @@ suite('AgentSessionRegistry', () => { }); }); + test('compatibility keys include legacy-only identities without changing current listing', async () => { + await database.registerSession(a.toString(), { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + const registry = createRegistry(); + + assert.deepStrictEqual({ + current: [...await registry.listSessionKeys()], + compatible: [...await registry.listRuntimeCompatibleSessionKeys()], + listed: await registry.list(), + }, { + current: [], + compatible: [a.toString()], + listed: [], + }); + }); + test('list migrates entries and returns the computed list without rereading', async () => { const testDatabase = new TestAgentHostDatabase(); database = testDatabase; @@ -302,6 +422,28 @@ suite('AgentSessionRegistry', () => { assert.deepStrictEqual((await list(registry)).map(s => s.session.toString()), [b.toString()]); }); + test('normal registration and unregister mirror the legacy registry', async () => { + const registry = createRegistry(); + await registerExplicit(registry, a, 'copilot', 100); + + assert.deepStrictEqual({ + legacy: await database.getSession(a.toString()), + current: await database.getSessionV2Registration(a.toString()), + }, { + legacy: { session: a.toString(), provider: 'copilot', startTime: 100, modifiedTime: 100, external: false, source: 'explicit' }, + current: { session: a.toString(), provider: 'copilot', startTime: 100, modifiedTime: 100, external: false, source: 'explicit' }, + }); + + await registry.unregister(a); + assert.deepStrictEqual({ + legacy: await database.getSession(a.toString()), + current: await database.getSessionV2Registration(a.toString()), + }, { + legacy: undefined, + current: undefined, + }); + }); + test('register preserves startTime and advances modifiedTime monotonically', async () => { const registry = createRegistry(); await registry.register(a, { provider: 'copilot', startTime: 100, modifiedTime: 150, source: 'explicit' }, { checkTombstone: false }); @@ -422,6 +564,35 @@ suite('AgentSessionRegistry', () => { ); }); + test('projection-versioned backfill markers are independent from legacy markers', async () => { + const registry = createRegistry(); + await registry.markBackfilled(); + await registry.markProviderBackfilled('copilot'); + + assert.deepStrictEqual({ + legacyGlobal: await registry.isBackfilled(), + legacyProvider: await registry.isProviderBackfilled('copilot'), + currentV4: await registry.isSessionsV2Backfilled('copilot', 4), + currentV5: await registry.isSessionsV2Backfilled('copilot', 5), + }, { + legacyGlobal: true, + legacyProvider: true, + currentV4: false, + currentV5: false, + }); + + await registry.markSessionsV2Backfilled('copilot', 5); + assert.deepStrictEqual({ + currentV4: await registry.isSessionsV2Backfilled('copilot', 4), + currentV5: await registry.isSessionsV2Backfilled('copilot', 5), + claudeV5: await registry.isSessionsV2Backfilled('claude', 5), + }, { + currentV4: false, + currentV5: true, + claudeV5: false, + }); + }); + test('register persistence failure can be retried', async () => { await database.close(); database = new TestAgentHostDatabase(); @@ -533,6 +704,27 @@ suite('AgentSessionRegistry', () => { assert.strictEqual(await registry.isTombstoned(a), false); }); + test('current-v2 exclusions are exposed and eligible registration clears them', async () => { + const registry = createRegistry(); + await registry.markSessionsV2Excluded({ + provider: 'copilot', + session: a.toString(), + reason: 'providerAbsent', + fingerprint: 'enumeration-v1', + }); + + assert.deepStrictEqual({ + single: await registry.getSessionsV2Exclusion('copilot', a), + list: await registry.listSessionsV2Exclusions('copilot'), + }, { + single: { provider: 'copilot', session: a.toString(), reason: 'providerAbsent', fingerprint: 'enumeration-v1' }, + list: [{ provider: 'copilot', session: a.toString(), reason: 'providerAbsent', fingerprint: 'enumeration-v1' }], + }); + + await registerDiscovered(registry, a, 'copilot', 100); + assert.strictEqual(await registry.getSessionsV2Exclusion('copilot', a), undefined); + }); + test('discovery declines to register (or resurrect) a tombstoned session', async () => { const registry = createRegistry(); await registerExplicit(registry, a, 'copilot', 100); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 03fac7f9484045..596c940615024b 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -5706,12 +5706,11 @@ suite('AgentSideEffects', () => { // Persist a custom title in the DB await sessionDb.setMetadata('customTitle', 'My Custom Title'); + await localService.whenCatalogReconciliationIdle(); const sessions = await localService.listSessions(); assert.strictEqual(sessions.length, 1); - // Custom title comes from the DB and is returned via the agent's listSessions - // The mock agent summary is used; the service doesn't read the DB for list - assert.ok(sessions[0].summary); + assert.strictEqual(sessions[0].summary, 'My Custom Title'); }); test('handleRestoreSession uses persisted custom title', async () => { diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 13c33567e8a619..accf984d97ba3a 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -1398,11 +1398,15 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual({ chatTitle: titles.stateManager.getChatState(titles.peerChat)?.title, + chatLocalTitle: await titles.database.getMetadata(SESSION_CUSTOM_TITLE_KEY), + chatLocalSource: await titles.database.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), persistedTitle: await titles.database.getMetadata(customChatTitleMetadataKey(titles.peerChat)), persistedSource: await titles.database.getMetadata(customChatTitleSourceMetadataKey(titles.peerChat)), renamedTitles: titles.titleController.renamedTitles, }, { chatTitle: 'Renamed peer', + chatLocalTitle: 'Renamed peer', + chatLocalSource: AGENT_HOST_TITLE_SOURCE_USER, persistedTitle: 'Renamed peer', persistedSource: AGENT_HOST_TITLE_SOURCE_USER, renamedTitles: [{ channel: titles.session, chatChannel: titles.peerChat }], @@ -2197,4 +2201,15 @@ suite('AgentHostChatContributions', () => { }, }); }); + + test('hydrates a chat-local title before the session compatibility mirror', async () => { + const contributions = createBuiltInContributions(disposables); + const chat = buildChatUri(contributions.session, 'peer'); + await contributions.database.setMetadata(SESSION_CUSTOM_TITLE_KEY, 'Chat-local title'); + await contributions.database.setMetadata(customChatTitleMetadataKey(chat), 'Legacy title'); + + assert.deepStrictEqual(await contributions.service.hydrateChat({ session: contributions.session, chat }, {}), { + title: 'Chat-local title', + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index a431135c28cd6e..6ac8a023808fa4 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -12413,21 +12413,25 @@ suite('CopilotAgent', () => { const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, userHome }); try { await agent.authenticate('https://api.github.com', 'token'); - await writeExtensionHostMarker(userHome, sessionId); + await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', customTitle: 'Legacy title' }); // Metadata an older build wrote: adopted, but without the provenance marker. const seed = sessionDataService.openDatabase(session); await seed.object.setMetadata('copilot.workingDirectory', URI.file(workingDirectory).toString()); + await seed.object.setMetadata('customTitle', 'Legacy title'); seed.dispose(); const adopted = await ensureDefaultChatAdopted(agent, session); const db = await sessionDataService.tryOpenDatabase(session); const marker = await db?.object.getMetadata('agentHost.ehcliAdopted'); + const title = await db?.object.getMetadata('customTitle'); + const titleSource = await db?.object.getMetadata('customTitleSource'); + const isRead = await db?.object.getMetadata(AH_META_IS_READ_DB_KEY); db?.dispose(); assert.deepStrictEqual( - { reason: adopted.reason, marker }, - { reason: 'alreadyNative', marker: 'true' }, + { reason: adopted.reason, marker, title, titleSource, isRead }, + { reason: 'alreadyNative', marker: 'true', title: 'Legacy title', titleSource: 'user', isRead: 'true' }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -12657,7 +12661,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { first, second, configValues }, - { first: { adopted: true, eligible: true, reason: 'adopted' }, second: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, + { first: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-adopt', titleSource: 'auto', isRead: true } }, second: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -12720,7 +12724,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, archived }, - { adopted: { adopted: true, eligible: true, reason: 'adopted' }, archived: 'true' }, + { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-archived', titleSource: 'auto', isRead: true } }, archived: 'true' }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -12758,7 +12762,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, usages }, { - adopted: { adopted: true, eligible: true, reason: 'adopted' }, + adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-credits', titleSource: 'auto', isRead: true } }, usages: [ ['evt-1', JSON.stringify({ model: 'gpt-5.4', _meta: { copilotUsage: { totalNanoAiu: 1_500_000_000 } } })], ['evt-2', JSON.stringify({ model: 'gpt-5.4-mini', _meta: { copilotUsage: { totalNanoAiu: 0 } } })], @@ -12786,14 +12790,26 @@ suite('CopilotAgent', () => { await writeExtensionHostMarker(userHome, sessionId, { origin: 'vscode', customTitle: 'My Legacy Session' }); const adopted = await ensureDefaultChatAdopted(agent, session); + const retried = await ensureDefaultChatAdopted(agent, session); const db = await sessionDataService.tryOpenDatabase(session); const customTitle = await db?.object.getMetadata('customTitle'); + const isRead = await db?.object.getMetadata(AH_META_IS_READ_DB_KEY); db?.dispose(); assert.deepStrictEqual( - { adopted, customTitle }, - { adopted: { adopted: true, eligible: true, reason: 'adopted' }, customTitle: 'My Legacy Session' }, + { adopted, retried, customTitle, isRead }, + { + adopted: { + adopted: true, + eligible: true, + reason: 'adopted', + listVisible: { title: 'My Legacy Session', titleSource: 'user', isRead: true }, + }, + retried: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, + customTitle: 'My Legacy Session', + isRead: 'true', + }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -12823,7 +12839,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, title }, - { adopted: { adopted: true, eligible: true, reason: 'adopted' }, title: 'Telemetry analysis for Agents window' }, + { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'Telemetry analysis for Agents window', titleSource: 'auto', isRead: true } }, title: 'Telemetry analysis for Agents window' }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -12853,7 +12869,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, title }, - { adopted: { adopted: true, eligible: true, reason: 'adopted' }, title: `SDK ${sessionId}` }, + { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: `SDK ${sessionId}`, titleSource: 'auto', isRead: true } }, title: `SDK ${sessionId}` }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -12882,7 +12898,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, isRead }, - { adopted: { adopted: true, eligible: true, reason: 'adopted' }, isRead: 'true' }, + { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-read', titleSource: 'auto', isRead: true } }, isRead: 'true' }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -12961,7 +12977,7 @@ suite('CopilotAgent', () => { const adopted = await ensureDefaultChatAdopted(agent, session); - assert.deepStrictEqual(adopted, { adopted: true, eligible: true, reason: 'adopted' }); + assert.deepStrictEqual(adopted, { adopted: true, eligible: true, reason: 'adopted', listVisible: { title: 'SDK legacy-originless', titleSource: 'auto', isRead: true } }); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); await fs.rm(workingDirectory, { recursive: true, force: true }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index f789ee53d7df0b..29de35824019d5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -220,6 +220,16 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) && (notification.params as SessionSummaryChangedParams).session === sessionUri && (((notification.params as SessionSummaryChangedParams).changes.status ?? 0) & SessionStatus.IsArchived) !== 0, ); + context.client.dispatch({ + channel: sessionUri, + clientSeq: 2, + action: { type: ActionType.SessionIsReadChanged, isRead: true }, + }); + await context.client.waitForNotification(notification => + notification.method === 'root/sessionSummaryChanged' + && (notification.params as SessionSummaryChangedParams).session === sessionUri + && (((notification.params as SessionSummaryChangedParams).changes.status ?? 0) & SessionStatus.IsRead) !== 0, + ); await restartAndInitialize(`archive-unrestored-verify-${config.provider}`, workspace); const after = await context.client.call('listSessions', { channel: ROOT_STATE_URI, includeArchived: true }); @@ -228,9 +238,11 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) assert.deepStrictEqual({ restored: restored !== undefined, isArchived: restored !== undefined && (restored.status & SessionStatus.IsArchived) !== 0, + isRead: restored !== undefined && (restored.status & SessionStatus.IsRead) !== 0, }, { restored: true, isArchived: true, + isRead: true, }); }); diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts index 879fb586b4fc38..beaeb187fd4db9 100644 --- a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -81,6 +81,26 @@ suite('SessionDataService', () => { await service.deleteSessionData(session); }); + test('tryOpenDatabase returns undefined only for a missing database and propagates stat errors', async () => { + const session = AgentSession.uri('copilot', 'probe-test'); + assert.strictEqual(await service.tryOpenDatabase(session), undefined); + + const failingScheme = 'failing-session-data'; + const failingFileService = disposables.add(new FileService(new NullLogService())); + class FailingStatProvider extends InMemoryFileSystemProvider { + override async stat(resource: URI) { + if (resource.path.endsWith('/session.db')) { + throw new Error('stat failed'); + } + return super.stat(resource); + } + } + disposables.add(failingFileService.registerProvider(failingScheme, disposables.add(new FailingStatProvider()))); + const failingService = new SessionDataService(URI.from({ scheme: failingScheme, path: '/userData' }), failingFileService, new NullLogService()); + + await assert.rejects(failingService.tryOpenDatabase(session), /stat failed/); + }); + test('cleanupOrphanedData deletes orphans but keeps known sessions', async () => { const baseDir = URI.joinPath(basePath, 'agentSessionData'); await fileService.createFolder(URI.joinPath(baseDir, 'keep-1')); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 1bec533c4334cc..0166c03929cee6 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; import { FileEditKind, MessageKind } from '../../common/state/sessionState.js'; -import type { IReviewedFileRecord } from '../../common/sessionDataService.js'; +import type { IReviewedFileRecord, ISessionCatalogSyncPendingSnapshot } from '../../common/sessionDataService.js'; import type { Database } from '@vscode/sqlite3'; import { generateUuid } from '../../../../base/common/uuid.js'; import { join } from '../../../../base/common/path.js'; @@ -78,6 +78,13 @@ suite('SessionDatabase', () => { }); } + async getRaw(sql: string): Promise | undefined> { + const rawDb = await this._ensureDb(); + return new Promise((resolve, reject) => { + rawDb.get(sql, (err: Error | null, row: Record | undefined) => err ? reject(err) : resolve(row)); + }); + } + /** Extract the raw db connection; this instance becomes inert. */ async ejectDb(): Promise { const rawDb = await this._ensureDb(); @@ -877,6 +884,420 @@ suite('SessionDatabase', () => { }); }); + suite('catalog sync snapshot', () => { + const snapshot = (sourceRevision: number, overrides: Partial = {}): ISessionCatalogSyncPendingSnapshot => ({ + sessionGeneration: 'generation-1', + sourceRevision, + projectionVersion: 1, + payload: `{"revision":${sourceRevision}}`, + payloadHash: `hash-${sourceRevision}`, + acknowledgedHash: undefined, + state: 'pending', + ...overrides, + }); + + const acknowledgedSnapshot = (sourceRevision: number) => ({ + sessionGeneration: 'generation-1', + sourceRevision, + projectionVersion: 1, + payload: undefined, + payloadHash: `hash-${sourceRevision}`, + acknowledgedHash: `hash-${sourceRevision}`, + state: 'acknowledged', + } as const); + + test('migration v10 creates the snapshot table on fresh databases', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + assert.ok((await db.getAllTables()).includes('catalog_sync_snapshot')); + }); + + test('migration v11 upgrades a v9 database', async () => { + const v9Database = await TestableSessionDatabase.open(':memory:', sessionDatabaseMigrations.slice(0, 9)); + await v9Database.setMetadata('customTitle', 'Before upgrade'); + const rawDatabase = await v9Database.ejectDb(); + + db = disposables.add(await TestableSessionDatabase.fromDb(rawDatabase)); + await db.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'After upgrade' }, snapshot(1)); + + assert.deepStrictEqual({ + tables: await db.getAllTables(), + title: await db.getMetadata('customTitle'), + snapshot: await db.getCatalogSyncSnapshot(), + }, { + tables: ['catalog_sync_snapshot', 'chat_drafts', 'file_edits', 'local_turns', 'reviewed_files', 'session_metadata', 'turn_delegation', 'turn_usage', 'turns'], + title: 'After upgrade', + snapshot: snapshot(1), + }); + }); + + test('migration v11 converges a catalog-only v10 database', async () => { + const catalogV10 = await TestableSessionDatabase.open(':memory:', sessionDatabaseMigrations.slice(0, 9)); + await catalogV10.runRaw(`CREATE TABLE catalog_sync_snapshot ( + singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), + session_generation TEXT NOT NULL CHECK (length(session_generation) > 0), + source_revision INTEGER NOT NULL CHECK (source_revision >= 0), + projection_version INTEGER NOT NULL CHECK (projection_version >= 0), + acknowledged_hash TEXT, + pending_hash TEXT, + pending_payload TEXT + )`); + await catalogV10.runRaw('PRAGMA user_version = 10'); + await catalogV10.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(1)); + const rawDatabase = await catalogV10.ejectDb(); + + const upgraded = disposables.add(await TestableSessionDatabase.fromDb(rawDatabase)); + + assert.deepStrictEqual({ + tables: await upgraded.getAllTables(), + snapshot: await upgraded.getCatalogSyncSnapshot(), + }, { + tables: ['catalog_sync_snapshot', 'chat_drafts', 'file_edits', 'local_turns', 'reviewed_files', 'session_metadata', 'turn_delegation', 'turn_usage', 'turns'], + snapshot: snapshot(1), + }); + }); + + test('migration v11 upgrades every published v1 through v9 schema', async () => { + const results: object[] = []; + for (let version = 1; version <= 9; version++) { + const priorDatabase = await TestableSessionDatabase.open(':memory:', sessionDatabaseMigrations.slice(0, version)); + const rawDatabase = await priorDatabase.ejectDb(); + const upgraded = await TestableSessionDatabase.fromDb(rawDatabase); + try { + results.push({ + version, + hasReceipt: (await upgraded.getAllTables()).includes('catalog_sync_snapshot'), + snapshot: await upgraded.getCatalogSyncSnapshot(), + }); + } finally { + await upgraded.close(); + } + } + + assert.deepStrictEqual(results, Array.from({ length: 9 }, (_, index) => ({ + version: index + 1, + hasReceipt: true, + snapshot: undefined, + }))); + }); + + test('atomically commits metadata and the snapshot', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + const result = await db.setMetadataValuesAndCatalogSyncSnapshot({ + customTitle: 'Catalog title', + isRead: 'true', + }, snapshot(1)); + + assert.deepStrictEqual({ + result, + metadata: await db.getMetadataObject({ customTitle: true, isRead: true }), + snapshot: await db.getCatalogSyncSnapshot(), + }, { + result: 'applied', + metadata: { customTitle: 'Catalog title', isRead: 'true' }, + snapshot: snapshot(1), + }); + }); + + test('rolls back metadata and snapshot together', async () => { + const database = disposables.add(await TestableSessionDatabase.open(':memory:')); + db = database; + await database.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Original title' }, snapshot(1)); + await database.runRaw(`CREATE TRIGGER fail_catalog_sync BEFORE UPDATE ON catalog_sync_snapshot + BEGIN SELECT RAISE(ABORT, 'snapshot write failed'); END`); + + await assert.rejects(() => database.setMetadataValuesAndCatalogSyncSnapshot({ + customTitle: 'Replacement title', + }, snapshot(2)), /snapshot write failed/); + + assert.deepStrictEqual({ + title: await database.getMetadata('customTitle'), + snapshot: await database.getCatalogSyncSnapshot(), + }, { + title: 'Original title', + snapshot: snapshot(1), + }); + }); + + test('treats an exact same-revision replay as idempotent', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Title' }, snapshot(1)); + await db.acknowledgeCatalogSyncSnapshot({ + sessionGeneration: 'generation-1', + sourceRevision: 1, + projectionVersion: 1, + payloadHash: 'hash-1', + }); + + const result = await db.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Different title' }, snapshot(1)); + + assert.deepStrictEqual({ + result, + title: await db.getMetadata('customTitle'), + snapshot: await db.getCatalogSyncSnapshot(), + }, { + result: 'replayed', + title: 'Title', + snapshot: acknowledgedSnapshot(1), + }); + }); + + test('transitions to a new generation with a lower revision through compare-and-swap', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Old generation' }, snapshot(100)); + const nextGeneration = snapshot(0, { + sessionGeneration: 'generation-2', + payload: '{"revision":0}', + payloadHash: 'generation-2-hash-0', + }); + + await assert.rejects( + () => db!.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Unguarded generation' }, nextGeneration), + /does not match stored generation/, + ); + const transitioned = await db.transitionMetadataValuesAndCatalogSyncSnapshot( + { customTitle: 'New generation' }, + 'generation-1', + nextGeneration, + ); + + assert.deepStrictEqual({ + transitioned, + title: await db.getMetadata('customTitle'), + snapshot: await db.getCatalogSyncSnapshot(), + }, { + transitioned: true, + title: 'New generation', + snapshot: nextGeneration, + }); + }); + + test('rejects a generation transition with the wrong expected generation', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Current generation' }, snapshot(100)); + const nextGeneration = snapshot(0, { + sessionGeneration: 'generation-2', + payload: '{"revision":0}', + payloadHash: 'generation-2-hash-0', + }); + + const transitioned = await db.transitionMetadataValuesAndCatalogSyncSnapshot( + { customTitle: 'Wrong transition' }, + 'unknown-generation', + nextGeneration, + ); + + assert.deepStrictEqual({ + transitioned, + title: await db.getMetadata('customTitle'), + snapshot: await db.getCatalogSyncSnapshot(), + }, { + transitioned: false, + title: 'Current generation', + snapshot: snapshot(100), + }); + }); + + test('delayed normal writes from an old generation cannot replace a transitioned generation', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Old generation' }, snapshot(100)); + const nextGeneration = snapshot(0, { + sessionGeneration: 'generation-2', + payload: '{"revision":0}', + payloadHash: 'generation-2-hash-0', + }); + await db.transitionMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'New generation' }, 'generation-1', nextGeneration); + + await assert.rejects( + () => db!.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Delayed old write' }, snapshot(101)), + /does not match stored generation/, + ); + + assert.deepStrictEqual({ + title: await db.getMetadata('customTitle'), + snapshot: await db.getCatalogSyncSnapshot(), + }, { + title: 'New generation', + snapshot: nextGeneration, + }); + }); + + test('rejects stale and conflicting updates without changing metadata', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Current title' }, snapshot(2)); + + await assert.rejects( + () => db!.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Stale title' }, snapshot(1)), + /stale/, + ); + for (const conflicting of [ + snapshot(2, { projectionVersion: 2 }), + snapshot(2, { payload: '{"different":true}' }), + snapshot(2, { payloadHash: 'different-hash' }), + ]) { + await assert.rejects( + () => db!.setMetadataValuesAndCatalogSyncSnapshot({ customTitle: 'Conflicting title' }, conflicting), + /conflicts/, + ); + } + + assert.deepStrictEqual({ + title: await db.getMetadata('customTitle'), + snapshot: await db.getCatalogSyncSnapshot(), + }, { + title: 'Current title', + snapshot: snapshot(2), + }); + }); + + test('acknowledges only the matching snapshot', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(1)); + + const acknowledged = await db.acknowledgeCatalogSyncSnapshot({ + sessionGeneration: 'generation-1', + sourceRevision: 1, + projectionVersion: 1, + payloadHash: 'hash-1', + }); + + assert.deepStrictEqual({ + acknowledged, + snapshot: await db.getCatalogSyncSnapshot(), + }, { + acknowledged: true, + snapshot: acknowledgedSnapshot(1), + }); + }); + + test('acknowledgement clears the pending payload and retains a compact hash receipt', async () => { + const database = disposables.add(await TestableSessionDatabase.open(':memory:')); + db = database; + const payload = 'x'.repeat(1024 * 1024); + await database.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(1, { payload })); + + await database.acknowledgeCatalogSyncSnapshot({ + sessionGeneration: 'generation-1', + sourceRevision: 1, + projectionVersion: 1, + payloadHash: 'hash-1', + }); + + assert.deepStrictEqual({ + snapshot: await database.getCatalogSyncSnapshot(), + storage: await database.getRaw(`SELECT acknowledged_hash, pending_hash, pending_payload, length(COALESCE(pending_payload, '')) AS pending_size + FROM catalog_sync_snapshot WHERE singleton_id = 1`), + }, { + snapshot: acknowledgedSnapshot(1), + storage: { + acknowledged_hash: 'hash-1', + pending_hash: null, + pending_payload: null, + pending_size: 0, + }, + }); + }); + + test('legacy metadata mutation can be compared with the acknowledged hash', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValuesAndCatalogSyncSnapshot({ catalogHash: 'hash-1' }, snapshot(1)); + await db.acknowledgeCatalogSyncSnapshot({ + sessionGeneration: 'generation-1', + sourceRevision: 1, + projectionVersion: 1, + payloadHash: 'hash-1', + }); + + await db.setMetadata('catalogHash', 'old-build-hash'); + const receipt = await db.getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + legacyHash: await db.getMetadata('catalogHash'), + acknowledgedHash: receipt?.acknowledgedHash, + }, { + legacyHash: 'old-build-hash', + acknowledgedHash: 'hash-1', + }); + }); + + test('a stale acknowledgement cannot clear newer pending work', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(1)); + await db.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(2)); + + const acknowledged = await db.acknowledgeCatalogSyncSnapshot({ + sessionGeneration: 'generation-1', + sourceRevision: 1, + projectionVersion: 1, + payloadHash: 'hash-1', + }); + + assert.deepStrictEqual({ + acknowledged, + snapshot: await db.getCatalogSyncSnapshot(), + }, { + acknowledged: false, + snapshot: snapshot(2), + }); + }); + + test('snapshot persists across a database restart', async () => { + const tempRoot = await fs.mkdtemp(join(tmpdir(), 'session-db-catalog-sync-' + generateUuid())); + const databasePath = join(tempRoot, 'session.db'); + try { + db = await SessionDatabase.open(databasePath); + await db.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(1)); + await db.close(); + db = await SessionDatabase.open(databasePath); + + assert.deepStrictEqual(await db.getCatalogSyncSnapshot(), snapshot(1)); + } finally { + await db?.close(); + db = undefined; + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + test('the latest snapshot remains pending when relay is interrupted', async () => { + const tempRoot = await fs.mkdtemp(join(tmpdir(), 'session-db-catalog-pending-' + generateUuid())); + const databasePath = join(tempRoot, 'session.db'); + try { + db = await SessionDatabase.open(databasePath); + await db.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(1)); + await db.acknowledgeCatalogSyncSnapshot({ + sessionGeneration: 'generation-1', + sourceRevision: 1, + projectionVersion: 1, + payloadHash: 'hash-1', + }); + await db.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(2)); + await db.close(); + db = await SessionDatabase.open(databasePath); + + assert.deepStrictEqual(await db.getCatalogSyncSnapshot(), snapshot(2, { acknowledgedHash: 'hash-1' })); + } finally { + await db?.close(); + db = undefined; + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + test('validates snapshot and acknowledgement boundaries', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await assert.rejects(() => db!.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(Number.MAX_SAFE_INTEGER + 1)), /safe integer/); + await assert.rejects(() => db!.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(1, { sessionGeneration: '' })), /sessionGeneration/); + await assert.rejects(() => db!.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(1, { payloadHash: '' })), /payloadHash/); + await assert.rejects(() => db!.acknowledgeCatalogSyncSnapshot({ + sessionGeneration: 'generation-1', + sourceRevision: -1, + projectionVersion: 1, + payloadHash: 'hash-1', + }), /safe integer/); + }); + }); + suite('chat drafts', () => { const chat = URI.parse('ahp-chat://default/Y29waWxvdDovLy9zZXNzaW9uLTE'); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index d99f1fb1e6ff40..bfd29a2c29e5db 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -101,6 +101,30 @@ External sessions remain provider-owned domain objects. Visibility and interacti Host-owned background activities remain independent of client visibility. Agent Merge monitoring prevents an enabled session from idle eviction while work is active, resumes eligible sessions after host startup, and releases that retention when monitoring ends. +### Host session catalog + +The local Agent Host maintains a host-wide `sessions_v2` SQLite registry and catalog. Each row contains a small indexed registry and synchronization envelope plus one bounded, versioned payload for list-visible session and chat metadata. The payload's structural validator is also its TypeScript type authority and normalizes all data before canonical serialization and hashing. + +The row has two different ownership contracts. Registry identity and provenance (`session_uri`, provider, start time, external state, and registration source) remain authoritative. The list payload is a derived, rebuildable aggregate: central session/chat identity, provider state, and member-chat metadata can reproduce its canonical bytes and hash. Ordinary session-list reads use this stored aggregate rather than opening every member-chat database. + +Peer-chat membership and routing data are authoritative in the central `session_chat_catalogs` and `session_chats` tables. The default chat is implicit in session identity; ordered peer rows retain their URI, provider backing, origin, and inherited-turn identity. A chat database owns its conversation content and chat-local metadata, including its durable provider backing and title. Central chat rows and the list payload retain only the copies needed to enumerate, route, and present the containing session. + +During the downgrade-compatibility window, a revisioned participant mirrors central peer membership into the legacy `peerChats` session-metadata value. Current runtime reads remain central. A startup/restore importer may read that legacy value to incorporate chats created by an older build; after import, central membership wins and the compatibility mirror is regenerated. Failed mirror writes do not roll back central authority and remain unacknowledged for retry. + +Catalog persistence is legacy-first during the compatibility window: one per-session transaction updates downgrade-compatible metadata and a durable pending catalog snapshot before the host-wide catalog is updated. Catalog updates are serialized per session, guarded by session incarnation and source revision, and acknowledged only after the central transaction succeeds. Background reconciliation replays interrupted writes and detects metadata written by older builds. A central monotonic dirty marker lets periodic passes skip clean rows before opening their per-session databases. The first pass after host startup marks every payload dirty once so writes made by older builds, which do not know about the marker, are still rechecked. Repair clears only the marker it observed; a concurrent mutation leaves the row dirty for another pass. Because provider state has no complete change signal, an infrequent safety sweep marks clean rows dirty after the normal dirty queue drains; ordinary periodic passes remain central-only. + +The per-session snapshot retains the canonical payload only while the central write is pending. Exact acknowledgement promotes its hash to the compact receipt and clears the pending payload/hash, so synchronized sessions do not permanently store a third copy of their list metadata. + +`sessions_v2` is independent of the predecessor `sessions` registry. The current-version importer unions existing v2 identities, optional predecessor registry rows, and provider discovery by session URI, then writes complete rows directly to v2. Payload-versioned per-provider markers record successful current enumeration without changing predecessor migration markers. Partial imports resume per session; durable exclusions make permanently ineligible candidates terminal and revivable by later discovery. + +Normal current-runtime mutations are authoritative in v2 and atomically mirror identity/provenance into `sessions` during the compatibility window so an intermediate build can see newly-created sessions. Direct migration remains v2-only. On returning from an intermediate build, the importer reconciles legacy-only additions and resolved legacy identity changes; legacy-row absence alone is never interpreted as deletion. Shared tombstones are the durable cross-version delete signal. + +An upsert atomically replaces the verified payload and its synchronization envelope while preserving the registered identity. It is guarded by the session incarnation and source revision. Concurrent first writers converge on the winning incarnation through a serialized retry. Older builds continue to read the mirrored predecessor metadata; no retained central generation is required. + +The indexed envelope also carries payload-derived top-level eligibility. Chat-backing sessions therefore remain hidden after restart without decoding their payload or opening their per-session database. For worktree sessions, both legacy metadata and the central payload derive the displayed project from the persisted repository root rather than the worktree checkout. + +Session listing resolves each registered session independently from its verified current-version payload. A missing, outdated, or malformed payload falls back to the legacy/provider source for that row and schedules reconciliation. A valid chat-backing envelope remains authoritative and never falls back into the top-level session list. + ## Local and remote boundary The local provider owns local runtime availability and local workspace access. Remote providers own: diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index cc4e1932dc199b..c7ccac319e6926 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -3404,7 +3404,7 @@ export function updateRanges(variableData: IChatRequestVariableData, promptText: if (offset >= edit.range.endExclusive) { mappedOffset += edit.newLength - oldLength; } else if (offset > edit.range.start) { - return Math.max(0, edit.range.start - leadingTrim + Math.min(offset - edit.range.start, edit.newLength)); + return Math.max(0, mappedOffset - (offset - edit.range.start) + Math.min(offset - edit.range.start, edit.newLength)); } } return Math.max(0, mappedOffset); diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts index 3dc7d769c40b06..72e79542a561c1 100644 --- a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts @@ -150,6 +150,70 @@ suite('ChatRequestParser', () => { }); }); + test('dynamic variable prompt text remaps ranges ending inside a later replacement', () => { + const text = ' aa xxx bb yyyyy cc'; + const firstStart = text.indexOf('xxx'); + const secondStart = text.indexOf('yyyyy'); + variableService.setDynamicVariables(testSessionUri, [{ + id: 'first', + fullName: 'xxx', + range: new Range(1, firstStart + 1, 1, firstStart + 4), + data: undefined, + promptText: 'XXXXXXXX', + }, { + id: 'second', + fullName: 'yyyyy', + range: new Range(1, secondStart + 1, 1, secondStart + 6), + data: undefined, + promptText: 'Z', + }]); + + parser = instantiationService.createInstance(ChatRequestParser); + const promptText = getPromptText(parser.parseChatRequest(testSessionUri, text)); + const variableData = updateRanges({ + variables: [{ + id: 'first', + name: 'first', + kind: 'generic', + value: undefined, + range: { start: firstStart, endExclusive: firstStart + 3 }, + }, { + id: 'second', + name: 'second', + kind: 'generic', + value: undefined, + range: { start: secondStart, endExclusive: secondStart + 5 }, + }, { + id: 'overlap', + name: 'overlap', + kind: 'generic', + value: undefined, + range: { start: secondStart - 2, endExclusive: secondStart + 3 }, + }, { + id: 'after', + name: 'after', + kind: 'generic', + value: undefined, + range: { start: text.indexOf('cc'), endExclusive: text.length }, + }], + }, promptText); + + assert.deepStrictEqual({ + message: promptText.message, + ranges: variableData.variables.map(variable => variable.range), + hasInvertedRanges: variableData.variables.some(variable => variable.range && variable.range.start > variable.range.endExclusive), + }, { + message: 'aa XXXXXXXX bb Z cc', + ranges: [ + { start: 3, endExclusive: 11 }, + { start: 15, endExclusive: 16 }, + { start: 13, endExclusive: 16 }, + { start: 17, endExclusive: 19 }, + ], + hasInvertedRanges: false, + }); + }); + test('multi-word #chat reference preserves its range through toVariableEntry', () => { // The reference carries the opaque backend chat URI verbatim. const chatResource = URI.parse('ahp-chat://chat-2/base64session');