From 49f24d87cd32d2a696e469d2c61fb8d0cada4cc9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 22 Aug 2026 11:53:57 +0200 Subject: [PATCH 01/30] agentHost: centralize session list metadata Add a backward-compatible sessions_v2 catalog, legacy-first synchronization receipts, reconciliation, shadow validation, central fallback reads, and durable chat metadata while retaining open-only content in per-session databases.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agent.ts | 10 + .../agentHost/common/sessionDataService.ts | 54 + .../node/agentHostCatalogListReader.ts | 119 ++ .../node/agentHostCatalogProjection.ts | 834 +++++++++ .../agentHostCatalogReconciliationService.ts | 414 +++++ .../node/agentHostCatalogShadowValidator.ts | 345 ++++ .../node/agentHostCatalogSyncService.ts | 263 +++ .../agentHost/node/agentHostDatabase.ts | 490 +++++- .../node/agentHostGitStateService.ts | 32 +- .../node/agentHostSessionTitleController.ts | 26 +- .../platform/agentHost/node/agentService.ts | 1276 ++++++++++---- .../agentHost/node/agentSideEffects.ts | 26 +- .../agentHost/node/copilot/copilotAgent.ts | 25 +- .../node/localCommands/localChatCommand.ts | 10 +- .../node/localCommands/renameLocalCommand.ts | 15 +- .../agentHost/node/sessionCoordination.ts | 6 +- .../agentHost/node/sessionDatabase.ts | 211 ++- .../test/common/sessionTestHelpers.ts | 105 +- .../node/agentHostCatalogListReader.test.ts | 181 ++ .../node/agentHostCatalogProjection.test.ts | 480 ++++++ ...ntHostCatalogReconciliationService.test.ts | 273 +++ .../agentHostCatalogShadowValidator.test.ts | 385 +++++ .../node/agentHostCatalogSyncService.test.ts | 385 +++++ .../test/node/agentHostDatabase.test.ts | 416 +++++ .../node/agentHostGitStateService.test.ts | 43 +- .../agentHostSessionTitleController.test.ts | 24 +- .../agentHost/test/node/agentService.test.ts | 1494 ++++++++++++++++- .../test/node/agentSessionRegistry.test.ts | 6 +- .../test/node/agentSideEffects.test.ts | 57 +- .../agentHost/test/node/copilotAgent.test.ts | 17 +- .../test/node/sessionCoordination.test.ts | 70 +- .../test/node/sessionDatabase.test.ts | 397 ++++- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 42 + 33 files changed, 8032 insertions(+), 499 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostCatalogListReader.ts create mode 100644 src/vs/platform/agentHost/node/agentHostCatalogProjection.ts create mode 100644 src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts create mode 100644 src/vs/platform/agentHost/node/agentHostCatalogShadowValidator.ts create mode 100644 src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCatalogShadowValidator.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index e1df75758448e1..7b05553c423061 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1032,6 +1032,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; + }; } /** diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 171cc7d6ebf71a..1c2eae482ba826 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 @@ -290,6 +324,26 @@ export interface ISessionDatabase extends IDisposable { */ setMetadataValues(values: 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/agentHostCatalogListReader.ts b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts new file mode 100644 index 00000000000000..bc683d2fa8cf1c --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AgentSession, type IAgentSessionMetadata } from '../common/agent.js'; +import { SessionArtifactType, withSessionArtifacts } from '../common/sessionArtifacts.js'; +import { SessionSourceControlOutcome, SessionStatus, withSessionEhcliAdoptable, withSessionExternal, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless } from '../common/state/sessionState.js'; +import { AGENT_HOST_CATALOG_PROJECTION_VERSION, parseAgentHostDatabaseCatalog, type IAgentHostCatalogSource } from './agentHostCatalogProjection.js'; +import type { IAgentHostDatabase } from './agentHostDatabase.js'; +import type { IRegisteredSession } from './agentSessionRegistry.js'; + +const artifactTypes = { + pullRequest: SessionArtifactType.PullRequest, + issue: SessionArtifactType.Issue, + commit: SessionArtifactType.Commit, + website: SessionArtifactType.Website, + file: SessionArtifactType.File, + resource: SessionArtifactType.Resource, +} as const; + +export type AgentHostCatalogListIneligibilityReason = + | 'missingCatalog' + | 'chatBacking' + | 'identityMismatch' + | 'providerMismatch' + | 'outdated' + | 'malformed' + | 'readError'; + +export type AgentHostCatalogListResult = + | { readonly eligible: true; readonly metadata: IAgentSessionMetadata; readonly source: IAgentHostCatalogSource } + | { readonly eligible: false; readonly reason: Exclude } + | { readonly eligible: false; readonly reason: 'readError'; readonly error: Error }; + +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 { eligible: false, reason: 'missingCatalog' }; + } + if (catalog.session !== session) { + return { eligible: false, reason: 'identityMismatch' }; + } + if (catalog.isChatBacking) { + return { eligible: false, reason: 'chatBacking' }; + } + if (AgentSession.provider(registered.session) !== registered.provider || catalog.provider !== registered.provider) { + return { eligible: false, reason: 'providerMismatch' }; + } + if (catalog.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { + return { eligible: false, reason: 'outdated' }; + } + const parsed = parseAgentHostDatabaseCatalog(catalog); + if (!parsed.ok) { + return { eligible: false, reason: 'malformed' }; + } + return { + eligible: true, + metadata: this._toSessionMetadata(registered, parsed.value.source), + source: parsed.value.source, + }; + } catch (error) { + return { + eligible: false, + reason: 'readError', + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + private _toSessionMetadata(registered: IRegisteredSession, source: IAgentHostCatalogSource): IAgentSessionMetadata { + let status = withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsRead, source.isRead); + status = withSessionStatusFlag(status, SessionStatus.IsArchived, source.isArchived); + + let meta = withSessionExternal(undefined, registered.external); + meta = withSessionWorkspaceless(meta, source.workspaceless); + if (source.ehcliAdoptable) { + meta = withSessionEhcliAdoptable(meta); + } + meta = withSessionMultiRootMetadata(meta, source.multiRoot); + meta = withSessionFolderPickerDecision(meta, source.folderPicker); + meta = withSessionGitHubState(meta, source.github); + meta = withSessionGitState(meta, source.git); + meta = withSessionSourceControlState(meta, source.sourceControl ? { + merge: source.sourceControl.merge, + latestOutcome: source.sourceControl.latestOutcome === 'merge' + ? SessionSourceControlOutcome.Merge + : source.sourceControl.latestOutcome === 'pullRequest' + ? SessionSourceControlOutcome.PullRequest + : undefined, + } : undefined); + meta = withSessionArtifacts(meta, source.artifacts?.map(artifact => ({ + ...artifact, + type: artifactTypes[artifact.type], + })) ?? []); + if (source.orchestration) { + meta = withSessionOrchestration(meta, source.orchestration); + } + + return { + session: registered.session, + startTime: registered.startTime, + modifiedTime: source.modifiedTime, + summary: source.title, + status, + project: source.project ? { uri: URI.parse(source.project.uri), displayName: source.project.displayName } : undefined, + workingDirectories: source.workingDirectories.map(directory => URI.parse(directory)), + changes: source.changes, + ...(meta !== undefined ? { _meta: meta } : {}), + }; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts new file mode 100644 index 00000000000000..4618845d62c954 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -0,0 +1,834 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { stableStringify } from '../../../base/common/objects.js'; +import type { AgentHostCatalogChatKind, AgentHostCatalogTitleSource, IAgentHostDatabaseCatalogChat, IAgentHostDatabaseSessionV2Projection } from './agentHostDatabase.js'; + +export const AGENT_HOST_CATALOG_PROJECTION_VERSION = 4; + +/** Each GitHub URL history is truncated to this many list-visible references. */ +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_STRUCTURED_FIELD_BYTE_LIMIT = 64 * 1024; +export const AGENT_HOST_CATALOG_SOURCE_PAYLOAD_BYTE_LIMIT = 4 * 1024 * 1024; + +const MAX_STRING_LENGTH = 4096; +const MAX_TITLE_LENGTH = 1024; +const MAX_JSON_DEPTH = 20; +const MAX_JSON_ENTRIES = 2000; + +export type AgentHostCatalogJsonValue = null | boolean | number | string | readonly AgentHostCatalogJsonValue[] | { readonly [key: string]: AgentHostCatalogJsonValue }; + +export interface IAgentHostCatalogProject { + readonly uri: string; + readonly displayName: string; +} + +export interface IAgentHostCatalogMultiRoot { + readonly workspaceFile: string; +} + +export interface IAgentHostCatalogFolderPickerDecision { + readonly hidden: boolean; + readonly primary?: string; +} + +export interface IAgentHostCatalogChangesSummary { + readonly additions?: number; + readonly deletions?: number; + readonly files?: number; +} + +export interface IAgentHostCatalogGitHubSummary { + readonly owner?: string; + readonly repo?: string; + readonly pullRequestUrls?: readonly string[]; + readonly initialPullRequestUrls?: readonly string[]; + readonly associatedPullRequestUrls?: readonly string[]; + readonly issueUrls?: readonly string[]; + readonly pullRequestBranchName?: string; +} + +export interface IAgentHostCatalogGitSummary { + readonly hasGitHubRemote?: boolean; + readonly branchName?: string; + readonly baseBranchName?: string; + readonly upstreamBranchName?: string; + readonly incomingChanges?: number; + readonly outgoingChanges?: number; + readonly uncommittedChanges?: number; + readonly hasBaseBranchChanges?: boolean; + readonly githubOwner?: string; + readonly githubHeadOwner?: string; + readonly githubRepo?: string; +} + +export type AgentHostCatalogSourceControlOutcome = 'merge' | 'pullRequest'; + +export interface IAgentHostCatalogSourceControlSummary { + readonly merge?: { + readonly commit: string; + }; + readonly latestOutcome?: AgentHostCatalogSourceControlOutcome; +} + +export type AgentHostCatalogArtifactType = 'pullRequest' | 'issue' | 'commit' | 'website' | 'file' | 'resource'; + +export interface IAgentHostCatalogArtifact { + readonly id: string; + readonly type: AgentHostCatalogArtifactType; + readonly label: string; + readonly link?: string; + readonly uri?: string; + readonly commitHash?: string; + readonly isGitHub?: boolean; + readonly createdByThisSession?: boolean; +} + +export interface IAgentHostCatalogOrchestration { + readonly parentSession: string; + readonly creatorSession: string; + readonly label?: string; + readonly coordinateWithCreator: boolean; + readonly notifyOnIdle?: 'once' | 'always'; + readonly creatorNotificationState?: 'waitingForCompletion' | 'notified'; +} + +export interface IAgentHostCatalogSourceChat { + readonly uri: string; + readonly order: number; + readonly kind: AgentHostCatalogChatKind; + readonly title?: string; + readonly titleSource?: AgentHostCatalogTitleSource; + readonly origin?: AgentHostCatalogJsonValue; +} + +/** + * Provider-neutral, list-visible session state. Hydrate-on-open content and + * transient activity state intentionally have no representation in this type. + */ +export interface IAgentHostCatalogSource { + readonly modifiedTime: number; + readonly title?: string; + readonly titleSource?: AgentHostCatalogTitleSource; + readonly isRead: boolean; + readonly isArchived: boolean; + readonly project?: IAgentHostCatalogProject; + readonly workspaceless: boolean; + readonly isChatBacking?: boolean; + readonly ehcliAdoptable?: boolean; + readonly multiRoot?: IAgentHostCatalogMultiRoot; + readonly folderPicker?: IAgentHostCatalogFolderPickerDecision; + readonly changes?: IAgentHostCatalogChangesSummary; + readonly github?: IAgentHostCatalogGitHubSummary; + readonly git?: IAgentHostCatalogGitSummary; + readonly sourceControl?: IAgentHostCatalogSourceControlSummary; + readonly artifacts?: readonly IAgentHostCatalogArtifact[]; + readonly orchestration?: IAgentHostCatalogOrchestration; + readonly workingDirectories: readonly string[]; + readonly chats: readonly IAgentHostCatalogSourceChat[]; +} + +export interface IAgentHostCatalogProjectionOptions { + readonly session: string; + readonly sessionGeneration: string; + readonly sourceRevision: number; +} + +export interface IAgentHostCatalogProjection { + readonly catalog: IAgentHostDatabaseSessionV2Projection; + readonly source: IAgentHostCatalogSource; + readonly sourcePayload: string; +} + +export interface IAgentHostCatalogValidationError { + readonly field: string; + readonly message: string; +} + +export type AgentHostCatalogValidationResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: IAgentHostCatalogValidationError }; + +class CatalogValidationError extends Error { + constructor(readonly field: string, message: string) { + super(message); + } +} + +const artifactTypes: ReadonlySet = new Set(['pullRequest', 'issue', 'commit', 'website', 'file', 'resource']); +const titleSources: ReadonlySet = new Set(['user', 'agent', 'auto']); +const chatKinds: ReadonlySet = new Set(['default', 'peer']); + +export function projectAgentHostCatalog(source: IAgentHostCatalogSource, options: IAgentHostCatalogProjectionOptions): AgentHostCatalogValidationResult { + return validate(() => { + const normalizedSource = normalizeSource(source); + const normalizedOptions = normalizeOptions(options); + const sourcePayload = stableStringify({ + projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + source: normalizedSource, + }); + if (!sourcePayload) { + fail('sourcePayload', 'Could not serialize the catalog source payload.'); + } + assertByteLength('sourcePayload', sourcePayload, AGENT_HOST_CATALOG_SOURCE_PAYLOAD_BYTE_LIMIT); + + const sourceHash = createHash('sha256').update(sourcePayload, 'utf8').digest('hex'); + const chats: readonly IAgentHostDatabaseCatalogChat[] = normalizedSource.chats.map(chat => ({ + uri: chat.uri, + order: chat.order, + kind: chat.kind, + title: chat.title, + titleSource: chat.titleSource, + originJson: stringifyStructuredField(`chats[${chat.order}].origin`, chat.origin), + })); + const catalog: IAgentHostDatabaseSessionV2Projection = { + session: normalizedOptions.session, + sessionGeneration: normalizedOptions.sessionGeneration, + modifiedTime: normalizedSource.modifiedTime, + title: normalizedSource.title, + titleSource: normalizedSource.titleSource, + isRead: normalizedSource.isRead, + isArchived: normalizedSource.isArchived, + projectUri: normalizedSource.project?.uri, + projectDisplayName: normalizedSource.project?.displayName, + workspaceless: normalizedSource.workspaceless, + isChatBacking: normalizedSource.isChatBacking ?? false, + ehcliAdoptable: normalizedSource.ehcliAdoptable, + multiRootJson: stringifyStructuredField('multiRoot', normalizedSource.multiRoot), + folderPickerJson: stringifyStructuredField('folderPicker', normalizedSource.folderPicker), + changesSummaryJson: stringifyStructuredField('changes', normalizedSource.changes), + githubSummaryJson: stringifyStructuredField('github', normalizedSource.github), + gitSummaryJson: stringifyStructuredField('git', normalizedSource.git), + sourceControlSummaryJson: stringifyStructuredField('sourceControl', normalizedSource.sourceControl), + artifactsJson: stringifyStructuredField('artifacts', normalizedSource.artifacts), + orchestrationJson: stringifyStructuredField('orchestration', normalizedSource.orchestration), + sourceRevision: normalizedOptions.sourceRevision, + projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + sourceHash, + verified: true, + workingDirectoriesJson: stringifyRequiredStructuredField('workingDirectories', normalizedSource.workingDirectories), + chatsJson: stringifyRequiredStructuredField('chats', chats), + }; + return { catalog, source: normalizedSource, sourcePayload }; + }); +} + +export function parseAgentHostCatalogSourcePayload(payload: string): AgentHostCatalogValidationResult> { + return validate(() => { + const parsed = parseJson('sourcePayload', payload, AGENT_HOST_CATALOG_SOURCE_PAYLOAD_BYTE_LIMIT); + const raw = requirePlainObject('sourcePayload', parsed); + requireExactKeys('sourcePayload', raw, ['projectionVersion', 'source']); + if (raw['projectionVersion'] !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { + fail('sourcePayload.projectionVersion', `Expected projection version ${AGENT_HOST_CATALOG_PROJECTION_VERSION}.`); + } + const source = normalizeSource(raw['source']); + const sourcePayload = stableStringify({ + projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + source, + }); + if (payload !== sourcePayload) { + fail('sourcePayload', 'Catalog source payload is not canonical.'); + } + return { source, sourcePayload }; + }); +} + +export function parseAgentHostDatabaseCatalog(catalog: IAgentHostDatabaseSessionV2Projection): AgentHostCatalogValidationResult { + return validate(() => { + const source = sourceFromCatalog(catalog); + const projected = unwrap(projectAgentHostCatalog(source, { + session: catalog.session, + sessionGeneration: catalog.sessionGeneration, + sourceRevision: catalog.sourceRevision, + })); + requireCatalogEqual(catalog, projected.catalog); + return projected; + }); +} + +function sourceFromCatalog(catalog: IAgentHostDatabaseSessionV2Projection): IAgentHostCatalogSource { + if (catalog.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { + fail('projectionVersion', `Expected projection version ${AGENT_HOST_CATALOG_PROJECTION_VERSION}.`); + } + const project = catalog.projectUri === undefined && catalog.projectDisplayName === undefined + ? undefined + : { + uri: requireString('projectUri', catalog.projectUri, MAX_STRING_LENGTH), + displayName: requireString('projectDisplayName', catalog.projectDisplayName, MAX_TITLE_LENGTH), + }; + return { + modifiedTime: catalog.modifiedTime, + title: catalog.title, + titleSource: catalog.titleSource, + isRead: catalog.isRead, + isArchived: catalog.isArchived, + project, + workspaceless: catalog.workspaceless, + isChatBacking: catalog.isChatBacking, + ehcliAdoptable: catalog.ehcliAdoptable ?? false, + multiRoot: parseOptionalStructuredField('multiRootJson', catalog.multiRootJson), + folderPicker: parseOptionalStructuredField('folderPickerJson', catalog.folderPickerJson), + changes: parseOptionalStructuredField('changesSummaryJson', catalog.changesSummaryJson), + github: parseOptionalStructuredField('githubSummaryJson', catalog.githubSummaryJson), + git: parseOptionalStructuredField('gitSummaryJson', catalog.gitSummaryJson), + sourceControl: parseOptionalStructuredField('sourceControlSummaryJson', catalog.sourceControlSummaryJson), + artifacts: parseOptionalStructuredField('artifactsJson', catalog.artifactsJson), + orchestration: parseOptionalStructuredField('orchestrationJson', catalog.orchestrationJson), + workingDirectories: normalizeWorkingDirectories(parseJson('workingDirectoriesJson', catalog.workingDirectoriesJson)), + chats: parseDatabaseChats(catalog.chatsJson).map(chat => { + return { + uri: chat.uri, + order: chat.order, + kind: chat.kind, + title: chat.title, + titleSource: chat.titleSource, + origin: chat.origin, + }; + }), + }; +} + +function normalizeSource(value: unknown): IAgentHostCatalogSource { + const raw = requirePlainObject('source', value); + requireExactKeys('source', raw, [ + 'modifiedTime', 'title', 'titleSource', 'isRead', 'isArchived', 'project', 'workspaceless', 'isChatBacking', + 'ehcliAdoptable', 'multiRoot', 'folderPicker', 'changes', 'github', 'git', 'sourceControl', 'artifacts', 'orchestration', + 'workingDirectories', 'chats' + ]); + const workingDirectories = normalizeWorkingDirectories(raw['workingDirectories']); + const chats = normalizeChats(raw['chats']); + return { + modifiedTime: requireSafeInteger('modifiedTime', raw['modifiedTime'], 0), + title: optionalString('title', raw['title'], MAX_TITLE_LENGTH), + titleSource: optionalTitleSource('titleSource', raw['titleSource']), + isRead: requireBoolean('isRead', raw['isRead']), + isArchived: requireBoolean('isArchived', raw['isArchived']), + project: normalizeProject(raw['project']), + workspaceless: requireBoolean('workspaceless', raw['workspaceless']), + isChatBacking: optionalBoolean('isChatBacking', raw['isChatBacking']) ?? false, + ehcliAdoptable: optionalBoolean('ehcliAdoptable', raw['ehcliAdoptable']) ?? false, + multiRoot: normalizeMultiRoot(raw['multiRoot']), + folderPicker: normalizeFolderPicker(raw['folderPicker']), + changes: normalizeChanges(raw['changes']), + github: normalizeGitHub(raw['github']), + git: normalizeGit(raw['git']), + sourceControl: normalizeSourceControl(raw['sourceControl']), + artifacts: normalizeArtifacts(raw['artifacts']), + orchestration: normalizeOrchestration(raw['orchestration']), + workingDirectories, + chats, + }; +} + +function normalizeOptions(value: IAgentHostCatalogProjectionOptions): IAgentHostCatalogProjectionOptions { + const raw = requirePlainObject('options', value); + requireExactKeys('options', raw, ['session', 'sessionGeneration', 'sourceRevision']); + return { + session: requireString('options.session', raw['session'], MAX_STRING_LENGTH), + sessionGeneration: requireString('options.sessionGeneration', raw['sessionGeneration'], MAX_STRING_LENGTH), + sourceRevision: requireSafeInteger('options.sourceRevision', raw['sourceRevision'], 0), + }; +} + +function normalizeProject(value: unknown): IAgentHostCatalogProject | undefined { + if (value === undefined) { + return undefined; + } + const raw = requirePlainObject('project', value); + requireExactKeys('project', raw, ['uri', 'displayName']); + return { + uri: requireString('project.uri', raw['uri'], MAX_STRING_LENGTH), + displayName: requireString('project.displayName', raw['displayName'], MAX_TITLE_LENGTH), + }; +} + +function normalizeMultiRoot(value: unknown): IAgentHostCatalogMultiRoot | undefined { + if (value === undefined) { + return undefined; + } + const raw = requirePlainObject('multiRoot', value); + requireExactKeys('multiRoot', raw, ['workspaceFile']); + return { workspaceFile: requireString('multiRoot.workspaceFile', raw['workspaceFile'], MAX_STRING_LENGTH) }; +} + +function normalizeFolderPicker(value: unknown): IAgentHostCatalogFolderPickerDecision | undefined { + if (value === undefined) { + return undefined; + } + const raw = requirePlainObject('folderPicker', value); + requireExactKeys('folderPicker', raw, ['hidden', 'primary']); + const hidden = requireBoolean('folderPicker.hidden', raw['hidden']); + const primary = optionalString('folderPicker.primary', raw['primary'], MAX_STRING_LENGTH); + if (primary !== undefined && !hidden) { + fail('folderPicker.primary', 'A pinned primary directory requires hidden to be true.'); + } + return primary === undefined ? { hidden } : { hidden, primary }; +} + +function normalizeChanges(value: unknown): IAgentHostCatalogChangesSummary | undefined { + if (value === undefined) { + return undefined; + } + const raw = requirePlainObject('changes', value); + requireExactKeys('changes', raw, ['additions', 'deletions', 'files']); + return { + additions: optionalSafeInteger('changes.additions', raw['additions'], 0), + deletions: optionalSafeInteger('changes.deletions', raw['deletions'], 0), + files: optionalSafeInteger('changes.files', raw['files'], 0), + }; +} + +function normalizeGitHub(value: unknown): IAgentHostCatalogGitHubSummary | undefined { + if (value === undefined) { + return undefined; + } + const raw = requirePlainObject('github', value); + requireExactKeys('github', raw, [ + 'owner', 'repo', 'pullRequestUrls', 'initialPullRequestUrls', 'associatedPullRequestUrls', + 'issueUrls', 'pullRequestBranchName' + ]); + return { + owner: optionalString('github.owner', raw['owner'], MAX_TITLE_LENGTH), + repo: optionalString('github.repo', raw['repo'], MAX_TITLE_LENGTH), + pullRequestUrls: normalizeGitHubReferences('github.pullRequestUrls', raw['pullRequestUrls']), + initialPullRequestUrls: normalizeGitHubReferences('github.initialPullRequestUrls', raw['initialPullRequestUrls']), + associatedPullRequestUrls: normalizeGitHubReferences('github.associatedPullRequestUrls', raw['associatedPullRequestUrls']), + issueUrls: normalizeGitHubReferences('github.issueUrls', raw['issueUrls']), + pullRequestBranchName: optionalString('github.pullRequestBranchName', raw['pullRequestBranchName'], MAX_TITLE_LENGTH), + }; +} + +function normalizeGit(value: unknown): IAgentHostCatalogGitSummary | undefined { + if (value === undefined) { + return undefined; + } + const raw = requirePlainObject('git', value); + requireExactKeys('git', raw, [ + 'hasGitHubRemote', 'branchName', 'baseBranchName', 'upstreamBranchName', 'incomingChanges', + 'outgoingChanges', 'uncommittedChanges', 'hasBaseBranchChanges', 'githubOwner', 'githubHeadOwner', + 'githubRepo' + ]); + const hasGitHubRemote = optionalBoolean('git.hasGitHubRemote', raw['hasGitHubRemote']); + const branchName = optionalString('git.branchName', raw['branchName'], MAX_TITLE_LENGTH); + const baseBranchName = optionalString('git.baseBranchName', raw['baseBranchName'], MAX_TITLE_LENGTH); + const upstreamBranchName = optionalString('git.upstreamBranchName', raw['upstreamBranchName'], MAX_TITLE_LENGTH); + const incomingChanges = optionalSafeInteger('git.incomingChanges', raw['incomingChanges'], 0); + const outgoingChanges = optionalSafeInteger('git.outgoingChanges', raw['outgoingChanges'], 0); + const uncommittedChanges = optionalSafeInteger('git.uncommittedChanges', raw['uncommittedChanges'], 0); + const hasBaseBranchChanges = optionalBoolean('git.hasBaseBranchChanges', raw['hasBaseBranchChanges']); + const githubOwner = optionalString('git.githubOwner', raw['githubOwner'], MAX_TITLE_LENGTH); + const githubHeadOwner = optionalString('git.githubHeadOwner', raw['githubHeadOwner'], MAX_TITLE_LENGTH); + const githubRepo = optionalString('git.githubRepo', raw['githubRepo'], MAX_TITLE_LENGTH); + return { + ...(hasGitHubRemote === undefined ? {} : { hasGitHubRemote }), + ...(branchName === undefined ? {} : { branchName }), + ...(baseBranchName === undefined ? {} : { baseBranchName }), + ...(upstreamBranchName === undefined ? {} : { upstreamBranchName }), + ...(incomingChanges === undefined ? {} : { incomingChanges }), + ...(outgoingChanges === undefined ? {} : { outgoingChanges }), + ...(uncommittedChanges === undefined ? {} : { uncommittedChanges }), + ...(hasBaseBranchChanges === undefined ? {} : { hasBaseBranchChanges }), + ...(githubOwner === undefined ? {} : { githubOwner }), + ...(githubHeadOwner === undefined ? {} : { githubHeadOwner }), + ...(githubRepo === undefined ? {} : { githubRepo }), + }; +} + +function normalizeGitHubReferences(field: string, value: unknown): readonly string[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value)) { + fail(field, 'Expected an array.'); + } + const seen = new Set(); + const result: string[] = []; + for (let index = 0; index < value.length && result.length < AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT; index++) { + const reference = requireString(`${field}[${index}]`, value[index], MAX_STRING_LENGTH); + const comparisonKey = reference.toLowerCase(); + if (!seen.has(comparisonKey)) { + seen.add(comparisonKey); + result.push(reference); + } + } + return result; +} + +function normalizeSourceControl(value: unknown): IAgentHostCatalogSourceControlSummary | undefined { + if (value === undefined) { + return undefined; + } + const raw = requirePlainObject('sourceControl', value); + requireExactKeys('sourceControl', raw, ['merge', 'latestOutcome']); + let merge: IAgentHostCatalogSourceControlSummary['merge']; + if (raw['merge'] !== undefined) { + const rawMerge = requirePlainObject('sourceControl.merge', raw['merge']); + requireExactKeys('sourceControl.merge', rawMerge, ['commit']); + merge = { commit: requireString('sourceControl.merge.commit', rawMerge['commit'], MAX_STRING_LENGTH) }; + } + const latestOutcome = raw['latestOutcome']; + if (latestOutcome !== undefined && latestOutcome !== 'merge' && latestOutcome !== 'pullRequest') { + fail('sourceControl.latestOutcome', 'Expected merge or pullRequest.'); + } + if (latestOutcome === 'merge' && merge === undefined) { + fail('sourceControl.merge', 'A merge outcome requires a commit.'); + } + return { merge, latestOutcome }; +} + +function normalizeArtifacts(value: unknown): readonly IAgentHostCatalogArtifact[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value)) { + fail('artifacts', 'Expected an array.'); + } + const ids = new Set(); + const retainedArtifacts = value.slice(-AGENT_HOST_CATALOG_ARTIFACT_LIMIT); + const retainedOffset = value.length - retainedArtifacts.length; + return retainedArtifacts.map((entry, retainedIndex) => { + const index = retainedOffset + retainedIndex; + const field = `artifacts[${index}]`; + const raw = requirePlainObject(field, entry); + requireExactKeys(field, raw, ['id', 'type', 'label', 'link', 'uri', 'commitHash', 'isGitHub', 'createdByThisSession']); + const id = requireString(`${field}.id`, raw['id'], MAX_STRING_LENGTH); + if (ids.has(id)) { + fail(`${field}.id`, `Duplicate artifact id '${id}'.`); + } + ids.add(id); + const type = raw['type']; + if (typeof type !== 'string' || !artifactTypes.has(type)) { + fail(`${field}.type`, 'Unsupported artifact type.'); + } + return { + id, + type: type as AgentHostCatalogArtifactType, + label: requireString(`${field}.label`, raw['label'], MAX_TITLE_LENGTH), + link: optionalString(`${field}.link`, raw['link'], MAX_STRING_LENGTH), + uri: optionalString(`${field}.uri`, raw['uri'], MAX_STRING_LENGTH), + commitHash: optionalString(`${field}.commitHash`, raw['commitHash'], MAX_STRING_LENGTH), + isGitHub: optionalBoolean(`${field}.isGitHub`, raw['isGitHub']), + createdByThisSession: optionalBoolean(`${field}.createdByThisSession`, raw['createdByThisSession']), + }; + }); +} + +function normalizeOrchestration(value: unknown): IAgentHostCatalogOrchestration | undefined { + if (value === undefined) { + return undefined; + } + const raw = requirePlainObject('orchestration', value); + requireExactKeys('orchestration', raw, [ + 'parentSession', 'creatorSession', 'label', 'coordinateWithCreator', 'notifyOnIdle', 'creatorNotificationState' + ]); + const notifyOnIdle = raw['notifyOnIdle']; + if (notifyOnIdle !== undefined && notifyOnIdle !== 'once' && notifyOnIdle !== 'always') { + fail('orchestration.notifyOnIdle', 'Expected once or always.'); + } + const creatorNotificationState = raw['creatorNotificationState']; + if (creatorNotificationState !== undefined && creatorNotificationState !== 'waitingForCompletion' && creatorNotificationState !== 'notified') { + fail('orchestration.creatorNotificationState', 'Unsupported creator notification state.'); + } + return { + parentSession: requireString('orchestration.parentSession', raw['parentSession'], MAX_STRING_LENGTH), + creatorSession: requireString('orchestration.creatorSession', raw['creatorSession'], MAX_STRING_LENGTH), + label: optionalString('orchestration.label', raw['label'], MAX_TITLE_LENGTH), + coordinateWithCreator: requireBoolean('orchestration.coordinateWithCreator', raw['coordinateWithCreator']), + notifyOnIdle, + creatorNotificationState, + }; +} + +function normalizeWorkingDirectories(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + fail('workingDirectories', 'Expected an array.'); + } + if (value.length > AGENT_HOST_CATALOG_CHILD_LIMIT) { + fail('workingDirectories', `Expected at most ${AGENT_HOST_CATALOG_CHILD_LIMIT} entries.`); + } + const seen = new Set(); + return value.map((entry, index) => { + const directory = requireString(`workingDirectories[${index}]`, entry, MAX_STRING_LENGTH); + if (seen.has(directory)) { + fail(`workingDirectories[${index}]`, `Duplicate working directory '${directory}'.`); + } + seen.add(directory); + return directory; + }); +} + +function normalizeChats(value: unknown): readonly IAgentHostCatalogSourceChat[] { + if (!Array.isArray(value)) { + fail('chats', 'Expected an array.'); + } + if (value.length > AGENT_HOST_CATALOG_CHILD_LIMIT) { + fail('chats', `Expected at most ${AGENT_HOST_CATALOG_CHILD_LIMIT} entries.`); + } + const uris = new Set(); + const orders = new Set(); + const chats = value.map((entry, index) => { + const field = `chats[${index}]`; + const raw = requirePlainObject(field, entry); + requireExactKeys(field, raw, ['uri', 'order', 'kind', 'title', 'titleSource', 'origin']); + const uri = requireString(`${field}.uri`, raw['uri'], MAX_STRING_LENGTH); + const order = requireSafeInteger(`${field}.order`, raw['order'], 0); + if (uris.has(uri)) { + fail(`${field}.uri`, `Duplicate chat URI '${uri}'.`); + } + if (orders.has(order)) { + fail(`${field}.order`, `Duplicate chat order '${order}'.`); + } + uris.add(uri); + orders.add(order); + const kind = raw['kind']; + if (typeof kind !== 'string' || !chatKinds.has(kind)) { + fail(`${field}.kind`, 'Unsupported chat kind.'); + } + const origin = raw['origin'] === undefined ? undefined : normalizeJsonValue(`${field}.origin`, raw['origin']); + return { + uri, + order, + kind: kind as AgentHostCatalogChatKind, + title: optionalString(`${field}.title`, raw['title'], MAX_TITLE_LENGTH), + titleSource: optionalTitleSource(`${field}.titleSource`, raw['titleSource']), + origin, + }; + }).sort((a, b) => a.order - b.order); + for (let index = 0; index < chats.length; index++) { + if (chats[index].order !== index) { + fail(`chats[${index}].order`, 'Chat orders must form a contiguous zero-based sequence.'); + } + } + return chats; +} + +function parseDatabaseChats(value: string): readonly IAgentHostCatalogSourceChat[] { + const parsed = parseJson('chatsJson', value); + if (!Array.isArray(parsed)) { + fail('chatsJson', 'Expected an array.'); + } + const sourceChats = parsed.map((entry, index) => { + const field = `chatsJson[${index}]`; + const raw = requirePlainObject(field, entry); + requireExactKeys(field, raw, ['uri', 'order', 'kind', 'title', 'titleSource', 'originJson']); + const originJson = raw['originJson']; + if (originJson !== undefined && typeof originJson !== 'string') { + fail(`${field}.originJson`, 'Expected a JSON string.'); + } + return { + uri: raw['uri'], + order: raw['order'], + kind: raw['kind'], + title: raw['title'], + titleSource: raw['titleSource'], + origin: originJson === undefined ? undefined : parseJson(`${field}.originJson`, originJson), + }; + }); + return normalizeChats(sourceChats); +} + +function normalizeJsonValue(field: string, value: unknown): AgentHostCatalogJsonValue { + let entries = 0; + const ancestors = new Set(); + const visit = (currentField: string, current: unknown, depth: number): AgentHostCatalogJsonValue => { + if (depth > MAX_JSON_DEPTH) { + fail(currentField, `JSON nesting exceeds ${MAX_JSON_DEPTH} levels.`); + } + if (current === null || typeof current === 'boolean' || typeof current === 'string') { + if (typeof current === 'string' && current.length > MAX_STRING_LENGTH) { + fail(currentField, `String exceeds ${MAX_STRING_LENGTH} characters.`); + } + return current; + } + if (typeof current === 'number') { + if (!Number.isFinite(current)) { + fail(currentField, 'Expected a finite JSON number.'); + } + return current; + } + if (typeof current !== 'object') { + fail(currentField, 'Expected a JSON-serializable value.'); + } + if (ancestors.has(current)) { + fail(currentField, 'Circular JSON values are not supported.'); + } + ancestors.add(current); + let result: AgentHostCatalogJsonValue; + if (Array.isArray(current)) { + entries += current.length; + checkJsonEntryLimit(field, entries); + result = current.map((entry, index) => visit(`${currentField}[${index}]`, entry, depth + 1)); + } else { + const raw = requirePlainObject(currentField, current); + const keys = Object.keys(raw).sort(); + entries += keys.length; + checkJsonEntryLimit(field, entries); + const normalized: { [key: string]: AgentHostCatalogJsonValue } = {}; + for (const key of keys) { + if (key.length > MAX_STRING_LENGTH) { + fail(currentField, `JSON key exceeds ${MAX_STRING_LENGTH} characters.`); + } + normalized[key] = visit(`${currentField}.${key}`, raw[key], depth + 1); + } + result = normalized; + } + ancestors.delete(current); + return result; + }; + const normalized = visit(field, value, 0); + assertStructuredFieldSize(field, stableStringify(normalized)); + return normalized; +} + +function parseOptionalStructuredField(field: string, value: string | undefined): T | undefined { + return value === undefined ? undefined : parseJson(field, value) as T; +} + +function stringifyStructuredField(field: string, value: AgentHostCatalogJsonValue | object | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + const result = stableStringify(value); + assertStructuredFieldSize(field, result); + return result; +} + +function stringifyRequiredStructuredField(field: string, value: AgentHostCatalogJsonValue | object): string { + const result = stringifyStructuredField(field, value); + if (result === undefined) { + fail(field, 'Could not serialize the structured field.'); + } + return result; +} + +function assertStructuredFieldSize(field: string, value: string): void { + if (!value) { + fail(field, 'Could not serialize the structured field.'); + } + assertByteLength(field, value, AGENT_HOST_CATALOG_STRUCTURED_FIELD_BYTE_LIMIT); +} + +function parseJson(field: string, value: string, maximumBytes = AGENT_HOST_CATALOG_STRUCTURED_FIELD_BYTE_LIMIT): unknown { + assertByteLength(field, value, maximumBytes); + try { + return JSON.parse(value); + } catch (error) { + fail(field, error instanceof Error ? error.message : 'Malformed JSON.'); + } +} + +function assertByteLength(field: string, value: string, maximumBytes: number): void { + if (Buffer.byteLength(value, 'utf8') > maximumBytes) { + fail(field, `Serialized value exceeds ${maximumBytes} bytes.`); + } +} + +function requireCatalogEqual(actual: IAgentHostDatabaseSessionV2Projection, expected: IAgentHostDatabaseSessionV2Projection): void { + const scalarFields: ReadonlyArray = [ + 'session', 'sessionGeneration', 'modifiedTime', 'title', 'titleSource', 'isRead', 'isArchived', + 'projectUri', 'projectDisplayName', 'workspaceless', 'isChatBacking', 'ehcliAdoptable', 'multiRootJson', 'folderPickerJson', 'changesSummaryJson', + 'githubSummaryJson', 'gitSummaryJson', 'sourceControlSummaryJson', 'artifactsJson', 'orchestrationJson', 'sourceRevision', + 'projectionVersion', 'sourceHash', 'verified', 'workingDirectoriesJson', 'chatsJson' + ]; + for (const field of scalarFields) { + if (actual[field] !== expected[field]) { + fail(field, 'Catalog field is not canonical or does not match its source hash.'); + } + } +} + +function requirePlainObject(field: string, value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + fail(field, 'Expected a plain object.'); + } + return value as Record; +} + +function requireExactKeys(field: string, value: Record, allowedKeys: readonly string[]): void { + const allowed = new Set(allowedKeys); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + fail(`${field}.${key}`, 'Unexpected field.'); + } + } +} + +function requireString(field: string, value: unknown, maximumLength: number): string { + if (typeof value !== 'string' || value.length === 0) { + fail(field, 'Expected a non-empty string.'); + } + if (value.length > maximumLength) { + fail(field, `String exceeds ${maximumLength} characters.`); + } + return value; +} + +function optionalString(field: string, value: unknown, maximumLength: number): string | undefined { + return value === undefined ? undefined : requireString(field, value, maximumLength); +} + +function requireBoolean(field: string, value: unknown): boolean { + if (typeof value !== 'boolean') { + fail(field, 'Expected a boolean.'); + } + return value; +} + +function optionalBoolean(field: string, value: unknown): boolean | undefined { + return value === undefined ? undefined : requireBoolean(field, value); +} + +function requireSafeInteger(field: string, value: unknown, minimum: number): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) { + fail(field, `Expected a safe integer greater than or equal to ${minimum}.`); + } + return value; +} + +function optionalSafeInteger(field: string, value: unknown, minimum: number): number | undefined { + return value === undefined ? undefined : requireSafeInteger(field, value, minimum); +} + +function optionalTitleSource(field: string, value: unknown): AgentHostCatalogTitleSource | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string' || !titleSources.has(value)) { + fail(field, 'Unsupported title source.'); + } + return value as AgentHostCatalogTitleSource; +} + +function checkJsonEntryLimit(field: string, entries: number): void { + if (entries > MAX_JSON_ENTRIES) { + fail(field, `JSON value exceeds ${MAX_JSON_ENTRIES} entries.`); + } +} + +function unwrap(result: AgentHostCatalogValidationResult): T { + if (!result.ok) { + throw new CatalogValidationError(result.error.field, result.error.message); + } + return result.value; +} + +function validate(callback: () => T): AgentHostCatalogValidationResult { + try { + return { ok: true, value: callback() }; + } catch (error) { + if (error instanceof CatalogValidationError) { + return { ok: false, error: { field: error.field, message: error.message } }; + } + throw error; + } +} + +function fail(field: string, message: string): never { + throw new CatalogValidationError(field, message); +} diff --git a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts new file mode 100644 index 00000000000000..f978ac30fdb1f8 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts @@ -0,0 +1,414 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { equals } from '../../../base/common/objects.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionDataService } from '../common/sessionDataService.js'; +import { AGENT_HOST_CATALOG_PROJECTION_VERSION, parseAgentHostCatalogSourcePayload, projectAgentHostCatalog } from './agentHostCatalogProjection.js'; +import { AgentHostCatalogSyncResult, AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; +import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase } 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_BACKGROUND_DELAY_MS = 1000; +const RECONCILIATION_CURSOR_STORAGE_KEY = 'agentHost.catalogReconciliation.cursor'; +type AgentHostCatalogSyncPendingReason = Extract['reason']; + +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: 'missingDatabase' | '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 backgroundDelayMs?: number; + readonly schedule?: (callback: () => void, delay: number) => IDisposable; +} + +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 _backgroundDelayMs: number; + private readonly _schedule: (callback: () => void, delay: number) => IDisposable; + private readonly _scheduledPass = this._register(new MutableDisposable()); + private _scheduledBackgroundPass = false; + private _running: Promise | undefined; + private _rerunRequested = false; + private _periodic = false; + + constructor( + private readonly _sessionDataService: ISessionDataService, + private readonly _catalogDatabase: IAgentHostDatabase, + private readonly _catalogSyncService: AgentHostCatalogSyncService, + private readonly _storageService: IAgentHostStorageService, + private readonly _listSessions: () => Promise, + private readonly _resolveSource: (registered: IRegisteredSession) => 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._backgroundDelayMs = this._nonNegativeInteger(options.backgroundDelayMs, DEFAULT_BACKGROUND_DELAY_MS, 'backgroundDelayMs'); + this._schedule = options.schedule ?? ((callback, delay) => disposableTimeout(callback, delay)); + } + + schedule(): void { + if (this._cancellation.token.isCancellationRequested) { + return; + } + this._periodic = true; + if (this._running) { + void this.runPass(); + return; + } + if (this._scheduledPass.value) { + return; + } + this._scheduledBackgroundPass = true; + this._scheduledPass.value = this._schedule(() => { + this._scheduledPass.clear(); + this._scheduledBackgroundPass = false; + this.start(); + }, this._backgroundDelayMs); + } + + start(): void { + if (this._cancellation.token.isCancellationRequested) { + return; + } + this._periodic = true; + this._scheduledPass.clear(); + this._scheduledBackgroundPass = false; + const wasRunning = this._running !== undefined; + const pass = this.runPass(); + if (wasRunning) { + return; + } + void pass.then( + report => this._logOutcomes(report.outcomes), + error => this._logService.error('[AgentHostCatalogReconciliation] Background pass failed', error), + ).finally(() => this._scheduleNextPass()); + } + + runPass(): Promise { + if (this._cancellation.token.isCancellationRequested) { + return Promise.resolve({ outcomes: [], cursor: this._readCursor() }); + } + if (this._running) { + this._rerunRequested = true; + return this._running; + } + this._running = this._runPassLoop().finally(() => { + this._running = undefined; + }); + return this._running; + } + + async whenIdle(): Promise { + if (this._scheduledBackgroundPass) { + this._scheduledPass.clear(); + this._scheduledBackgroundPass = false; + this.start(); + } + while (this._running) { + await this._running; + } + await this._storageService.whenIdle(); + } + + override dispose(): void { + this._periodic = false; + this._cancellation.cancel(); + super.dispose(); + } + + private async _runPassLoop(): Promise { + let report = await this._runSinglePass(this._cancellation.token); + 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 { + const sessions = [...await this._listSessions()].sort((a, b) => a.session.toString().localeCompare(b.session.toString())); + if (sessions.length === 0) { + this._storageService.delete(this._cursorStorageKey); + return { outcomes: [], cursor: undefined }; + } + + const selected = this._selectBatch(sessions, this._readCursor()); + const limiter = new Limiter(this._concurrency); + const outcomes = await Promise.all(selected.map(registered => limiter.queue(() => this._reconcileSession(registered, token)))); + const cursor = selected.at(-1)?.session.toString(); + if (cursor && !token.isCancellationRequested) { + this._storageService.set(this._cursorStorageKey, cursor); + } + return { outcomes, cursor }; + } + + private async _reconcileSession(registered: IRegisteredSession, 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 database = await this._sessionDataService.tryOpenDatabase(session); + if (!database) { + return { session: sessionKey, status: 'retry', reason: 'missingDatabase' }; + } + try { + const snapshot = await database.object.getCatalogSyncSnapshot(); + let replayedRevision: number | undefined; + if (snapshot?.state === 'pending') { + const replay = await this._catalogSyncService.runExclusive( + session, + async () => { + const current = await database.object.getCatalogSyncSnapshot(); + if (current?.state !== 'pending') { + return { + session: sessionKey, + status: 'succeeded', + reason: 'pendingReplayed', + sourceRevision: current?.sourceRevision ?? snapshot.sourceRevision, + } satisfies Extract; + } + return this._replayPending(session, current, acknowledgement => database.object.acknowledgeCatalogSyncSnapshot(acknowledgement), token); + }, + ); + if (replay.status !== 'succeeded') { + if (replay.status !== 'retry' || (replay.reason !== 'staleIncarnation' && replay.reason !== 'missingCatalog')) { + return replay; + } + } else { + replayedRevision = replay.sourceRevision; + } + } + + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + const currentSnapshot = await database.object.getCatalogSyncSnapshot(); + const sourceResult = await this._resolveSource(registered); + if (sourceResult.status === 'providerUnavailable') { + return { session: sessionKey, status: 'retry', reason: 'providerUnavailable' }; + } + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + const metadataKeys: Record = {}; + for (const key of Object.keys(sourceResult.request.legacyMetadata)) { + metadataKeys[key] = true; + } + const persistedMetadata = await database.object.getMetadataObject(metadataKeys); + const legacyMetadataMatches = Object.entries(sourceResult.request.legacyMetadata) + .every(([key, value]) => persistedMetadata[key] === value); + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + + const central = await this._catalogDatabase.getSessionV2(sessionKey); + if (currentSnapshot?.state === 'acknowledged' && central) { + const expected = projectAgentHostCatalog(sourceResult.request.source, { + session: sessionKey, + sessionGeneration: central.sessionGeneration, + sourceRevision: currentSnapshot.sourceRevision, + }); + if (expected.ok + && currentSnapshot.projectionVersion === AGENT_HOST_CATALOG_PROJECTION_VERSION + && currentSnapshot.payloadHash === expected.value.catalog.sourceHash + && currentSnapshot.sessionGeneration === central.sessionGeneration + && currentSnapshot.sourceRevision === central.sourceRevision + && currentSnapshot.projectionVersion === central.projectionVersion + && currentSnapshot.payloadHash === central.sourceHash + && legacyMetadataMatches + && equals(central, { ...expected.value.catalog, provider: central.provider, startTime: central.startTime, external: central.external, source: central.source })) { + return replayedRevision === undefined + ? { session: sessionKey, status: 'skipped', reason: 'synchronized' } + : { session: sessionKey, status: 'succeeded', reason: 'pendingReplayed', sourceRevision: replayedRevision }; + } + } + + 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 this._catalogSyncService.synchronize(session, sourceResult.request); + return synchronized.status === 'acknowledged' + ? { session: sessionKey, status: 'succeeded', reason: 'synchronized', sourceRevision: synchronized.sourceRevision } + : { session: sessionKey, status: 'pending', reason: synchronized.reason, sourceRevision: synchronized.sourceRevision }; + } finally { + database.dispose(); + } + } catch (error) { + 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 async _replayPending( + session: URI, + snapshot: ISessionCatalogSyncPendingSnapshot, + acknowledge: (acknowledgement: ISessionCatalogSyncAcknowledgement) => Promise, + token: CancellationToken, + ): Promise> { + const sessionKey = session.toString(); + const parsed = parseAgentHostCatalogSourcePayload(snapshot.payload); + if (!parsed.ok || snapshot.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { + return { session: sessionKey, status: 'failed', reason: 'malformedPayload', error: parsed.ok ? 'Unsupported projection version' : `${parsed.error.field}: ${parsed.error.message}` }; + } + 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' }; + } + + const projection = projectAgentHostCatalog(parsed.value.source, { + session: sessionKey, + sessionGeneration: snapshot.sessionGeneration, + sourceRevision: snapshot.sourceRevision, + }); + if (!projection.ok || projection.value.sourcePayload !== snapshot.payload || projection.value.catalog.sourceHash !== snapshot.payloadHash) { + return { session: sessionKey, status: 'failed', reason: 'payloadMismatch', error: projection.ok ? 'Payload hash does not match canonical source' : `${projection.error.field}: ${projection.error.message}` }; + } + 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(projection.value.catalog, central?.sessionGeneration); + } catch (error) { + return { session: sessionKey, status: 'failed', reason: 'centralApplyFailed', error: error instanceof Error ? error.message : String(error) }; + } + 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 _selectBatch(sessions: readonly IRegisteredSession[], cursor: string | undefined): readonly IRegisteredSession[] { + const start = cursor === undefined ? 0 : Math.max(0, sessions.findIndex(session => session.session.toString() > cursor)); + 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._cancellation.token.isCancellationRequested) { + return; + } + this._scheduledPass.value = this._schedule(() => { + this._scheduledPass.clear(); + this.start(); + }, this._intervalMs); + } + + 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/agentHostCatalogShadowValidator.ts b/src/vs/platform/agentHost/node/agentHostCatalogShadowValidator.ts new file mode 100644 index 00000000000000..866f229d12b212 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogShadowValidator.ts @@ -0,0 +1,345 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { equals } from '../../../base/common/objects.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentSession, type IAgentSessionMetadata } from '../common/agent.js'; +import { readSessionArtifacts } from '../common/sessionArtifacts.js'; +import { isSessionStatusArchived, isSessionStatusRead, readSessionEhcliAdoptable, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionOrchestration, readSessionSourceControlState, readSessionWorkspaceless } from '../common/state/sessionState.js'; +import { parseAgentHostDatabaseCatalog, projectAgentHostCatalog, type IAgentHostCatalogSource } from './agentHostCatalogProjection.js'; +import type { IAgentHostDatabase } from './agentHostDatabase.js'; +import type { IRegisteredSession } from './agentSessionRegistry.js'; + +const DEFAULT_CONCURRENCY = 4; + +export type AgentHostCatalogReadMode = 'legacy' | 'shadow' | 'centralWithFallback' | 'central'; + +export const agentHostCatalogShadowDiagnosticCategories = [ + 'matched', + 'missing', + 'malformed', + 'validationError', + 'identityMismatch', + 'providerMismatch', + 'startTimeMismatch', + 'modifiedTimeMismatch', + 'titleMismatch', + 'readMismatch', + 'archiveMismatch', + 'projectMismatch', + 'workspacelessMismatch', + 'adoptableMismatch', + 'multiRootMismatch', + 'folderPickerMismatch', + 'changesMismatch', + 'githubMismatch', + 'gitMismatch', + 'sourceControlMismatch', + 'artifactsMismatch', + 'orchestrationMismatch', + 'workingDirectoriesMismatch', + 'topLevelEligibilityMismatch', + 'titleSourceNotComparable', + 'chatsNotComparable', +] as const; + +export type AgentHostCatalogShadowDiagnosticCategory = typeof agentHostCatalogShadowDiagnosticCategories[number]; + +export interface IAgentHostCatalogShadowValidationReport { + readonly total: number; + readonly counts: Readonly>; +} + +export interface IAgentHostCatalogShadowValidationReporter { + report(report: IAgentHostCatalogShadowValidationReport): void; +} + +export interface IAgentHostCatalogShadowValidatorOptions { + readonly concurrency?: number; +} + +interface ISessionValidation { + readonly categories: readonly AgentHostCatalogShadowDiagnosticCategory[]; + readonly repair: boolean; +} + +interface IValidationRequest { + readonly legacySessions: readonly IAgentSessionMetadata[]; + readonly registeredSessions: readonly IRegisteredSession[]; +} + +const repairableMismatchCategories: ReadonlySet = new Set([ + 'identityMismatch', + 'modifiedTimeMismatch', + 'titleMismatch', + 'readMismatch', + 'archiveMismatch', + 'projectMismatch', + 'workspacelessMismatch', + 'adoptableMismatch', + 'multiRootMismatch', + 'folderPickerMismatch', + 'changesMismatch', + 'githubMismatch', + 'gitMismatch', + 'sourceControlMismatch', + 'artifactsMismatch', + 'orchestrationMismatch', + 'workingDirectoriesMismatch', + 'topLevelEligibilityMismatch', +]); + +export class AgentHostCatalogShadowValidator { + + private readonly _concurrency: number; + private _activeValidation: Promise | undefined; + private _pendingValidation: IValidationRequest | undefined; + + constructor( + private readonly _catalogDatabase: IAgentHostDatabase, + private readonly _reporter: IAgentHostCatalogShadowValidationReporter, + private readonly _scheduleRepair: () => void, + private readonly _logService: ILogService, + options: IAgentHostCatalogShadowValidatorOptions = {}, + ) { + const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY; + if (!Number.isInteger(concurrency) || concurrency <= 0) { + throw new Error('Agent Host catalog shadow validation concurrency must be a positive integer'); + } + this._concurrency = concurrency; + } + + schedule(legacySessions: readonly IAgentSessionMetadata[], registeredSessions: readonly IRegisteredSession[]): void { + this._pendingValidation = { + legacySessions: [...legacySessions], + registeredSessions: [...registeredSessions], + }; + if (!this._activeValidation) { + this._startPendingValidation(); + } + } + + async validate(legacySessions: readonly IAgentSessionMetadata[], registeredSessions: readonly IRegisteredSession[]): Promise { + const registeredBySession = new Map(registeredSessions.map(registered => [registered.session.toString(), registered])); + const legacyBySession = new Map(legacySessions.map(legacy => [legacy.session.toString(), legacy])); + const limiter = new Limiter(this._concurrency); + const validations = await Promise.all([ + ...legacySessions.map(legacy => limiter.queue(async () => { + try { + return await this._validateSession(legacy, registeredBySession.get(legacy.session.toString())); + } catch { + return { categories: ['validationError', 'titleSourceNotComparable', 'chatsNotComparable'], repair: true }; + } + })), + ...registeredSessions + .filter(registered => !legacyBySession.has(registered.session.toString())) + .map(registered => limiter.queue(async () => { + try { + return await this._validateCentralOnlySession(registered); + } catch { + return { categories: ['validationError', 'titleSourceNotComparable', 'chatsNotComparable'], repair: true }; + } + })), + ]); + const counts = this._emptyCounts(); + let repair = false; + for (const validation of validations) { + repair ||= validation.repair; + for (const category of validation.categories) { + counts[category]++; + } + } + const report: IAgentHostCatalogShadowValidationReport = { total: validations.length, counts }; + this._logService.info(`[AgentHostCatalogShadowValidator] ${JSON.stringify(report)}`); + if (repair) { + try { + this._scheduleRepair(); + } catch { + this._logService.warn('[AgentHostCatalogShadowValidator] Failed to schedule catalog reconciliation'); + } + } + try { + this._reporter.report(report); + } catch { + this._logService.warn('[AgentHostCatalogShadowValidator] Diagnostic reporter failed'); + } + } + + private _startPendingValidation(): void { + const request = this._pendingValidation; + if (!request) { + return; + } + this._pendingValidation = undefined; + const validation = Promise.resolve().then(() => this.validate(request.legacySessions, request.registeredSessions)); + this._activeValidation = validation; + void validation.then( + () => this._completeValidation(validation), + () => { + this._logService.warn('[AgentHostCatalogShadowValidator] Background validation failed'); + this._completeValidation(validation); + }, + ); + } + + private _completeValidation(validation: Promise): void { + if (this._activeValidation !== validation) { + return; + } + this._activeValidation = undefined; + this._startPendingValidation(); + } + + private async _validateSession(legacy: IAgentSessionMetadata, registered: IRegisteredSession | undefined): Promise { + const categories: AgentHostCatalogShadowDiagnosticCategory[] = ['titleSourceNotComparable', 'chatsNotComparable']; + const session = legacy.session.toString(); + if (!registered) { + categories.push('missing'); + return { categories, repair: true }; + } + const catalog = await this._catalogDatabase.getSessionV2(session); + if (!catalog) { + categories.push('missing'); + return { categories, repair: true }; + } + if (catalog.session !== session) { + categories.push('identityMismatch'); + return { categories, repair: true }; + } + if (catalog.isChatBacking) { + categories.push('topLevelEligibilityMismatch'); + return { categories, repair: true }; + } + const parsed = parseAgentHostDatabaseCatalog(catalog); + if (!parsed.ok) { + categories.push('malformed'); + return { categories, repair: true }; + } + + const legacyProjection = projectAgentHostCatalog(this._legacySource(legacy), { + session, + sessionGeneration: catalog.sessionGeneration, + sourceRevision: catalog.sourceRevision, + }); + if (!legacyProjection.ok) { + categories.push('validationError'); + return { categories, repair: false }; + } + + const expected = legacyProjection.value.source; + const actual = parsed.value.source; + if (AgentSession.provider(legacy.session) !== registered.provider || registered.provider !== catalog.provider) { + categories.push('providerMismatch'); + } + if (legacy.startTime !== registered.startTime || registered.startTime !== catalog.startTime) { + categories.push('startTimeMismatch'); + } + this._compare(categories, 'modifiedTimeMismatch', expected.modifiedTime, actual.modifiedTime); + this._compare(categories, 'titleMismatch', expected.title, actual.title); + this._compare(categories, 'readMismatch', expected.isRead, actual.isRead); + this._compare(categories, 'archiveMismatch', expected.isArchived, actual.isArchived); + this._compare(categories, 'projectMismatch', expected.project, actual.project); + this._compare(categories, 'workspacelessMismatch', expected.workspaceless, actual.workspaceless); + this._compare(categories, 'adoptableMismatch', expected.ehcliAdoptable, actual.ehcliAdoptable); + this._compare(categories, 'multiRootMismatch', expected.multiRoot, actual.multiRoot); + this._compare(categories, 'folderPickerMismatch', expected.folderPicker, actual.folderPicker); + this._compare(categories, 'changesMismatch', expected.changes, actual.changes); + this._compare(categories, 'githubMismatch', expected.github, actual.github); + this._compare(categories, 'gitMismatch', expected.git, actual.git); + this._compare(categories, 'sourceControlMismatch', expected.sourceControl, actual.sourceControl); + this._compare(categories, 'artifactsMismatch', expected.artifacts, actual.artifacts); + this._compare(categories, 'orchestrationMismatch', expected.orchestration, actual.orchestration); + this._compare(categories, 'workingDirectoriesMismatch', expected.workingDirectories, actual.workingDirectories); + + const mismatches = categories.filter(category => category.endsWith('Mismatch')); + if (mismatches.length === 0) { + categories.push('matched'); + } + return { + categories, + repair: mismatches.some(category => repairableMismatchCategories.has(category)), + }; + } + + private async _validateCentralOnlySession(registered: IRegisteredSession): Promise { + const categories: AgentHostCatalogShadowDiagnosticCategory[] = ['titleSourceNotComparable', 'chatsNotComparable']; + const catalog = await this._catalogDatabase.getSessionV2(registered.session.toString()); + if (!catalog) { + categories.push('missing'); + return { categories, repair: true }; + } + if (catalog.isChatBacking) { + categories.push('matched'); + return { categories, repair: false }; + } + categories.push('topLevelEligibilityMismatch'); + return { categories, repair: true }; + } + + private _legacySource(legacy: IAgentSessionMetadata): IAgentHostCatalogSource { + return { + modifiedTime: legacy.modifiedTime, + title: legacy.summary || undefined, + isRead: isSessionStatusRead(legacy.status), + isArchived: isSessionStatusArchived(legacy.status), + project: legacy.project ? { uri: legacy.project.uri.toString(), displayName: legacy.project.displayName } : undefined, + workspaceless: readSessionWorkspaceless(legacy._meta), + ehcliAdoptable: readSessionEhcliAdoptable(legacy._meta), + multiRoot: readSessionMultiRootMetadata(legacy._meta), + folderPicker: readSessionFolderPickerDecision(legacy._meta), + changes: legacy.changes, + github: readSessionGitHubState(legacy._meta), + git: readSessionGitState(legacy._meta), + sourceControl: readSessionSourceControlState(legacy._meta), + artifacts: readSessionArtifacts(legacy._meta), + orchestration: readSessionOrchestration(legacy._meta), + workingDirectories: legacy.workingDirectories?.map(directory => directory.toString()) ?? [], + chats: [], + }; + } + + private _compare( + categories: AgentHostCatalogShadowDiagnosticCategory[], + category: AgentHostCatalogShadowDiagnosticCategory, + expected: unknown, + actual: unknown, + ): void { + if (!equals(expected, actual)) { + categories.push(category); + } + } + + private _emptyCounts(): Record { + return { + matched: 0, + missing: 0, + malformed: 0, + validationError: 0, + identityMismatch: 0, + providerMismatch: 0, + startTimeMismatch: 0, + modifiedTimeMismatch: 0, + titleMismatch: 0, + readMismatch: 0, + archiveMismatch: 0, + projectMismatch: 0, + workspacelessMismatch: 0, + adoptableMismatch: 0, + multiRootMismatch: 0, + folderPickerMismatch: 0, + changesMismatch: 0, + githubMismatch: 0, + gitMismatch: 0, + sourceControlMismatch: 0, + artifactsMismatch: 0, + orchestrationMismatch: 0, + workingDirectoriesMismatch: 0, + topLevelEligibilityMismatch: 0, + titleSourceNotComparable: 0, + chatsNotComparable: 0, + }; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts new file mode 100644 index 00000000000000..5bc3a3cbbb8493 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ILogService } from '../../log/common/log.js'; +import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionCatalogSyncSnapshot, ISessionDataService } from '../common/sessionDataService.js'; +import { AGENT_HOST_CATALOG_PROJECTION_VERSION, IAgentHostCatalogSource, projectAgentHostCatalog } from './agentHostCatalogProjection.js'; +import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseSessionV2 } from './agentHostDatabase.js'; + +const INITIAL_SOURCE_REVISION = 0; +const MAX_GENERATION_RETRIES = 3; + +export interface IAgentHostCatalogSyncRequest { + readonly source: IAgentHostCatalogSource; + readonly legacyMetadata: Readonly>; +} + +export type AgentHostCatalogSyncResult = + | { readonly status: 'acknowledged'; readonly sourceRevision: number } + | { readonly status: 'pending'; readonly sourceRevision: number; readonly reason: AgentHostDatabaseSessionV2UpsertResult | 'upsertFailed' | 'acknowledgementSuperseded' }; + +interface IQueuedOperation { + readonly run: () => Promise; +} + +interface ISessionSyncQueue { + running: boolean; + readonly pending: IQueuedOperation[]; +} + +export class AgentHostCatalogSyncService { + + private readonly _queues = new Map(); + + constructor( + private readonly _sessionDataService: ISessionDataService, + private readonly _catalogDatabase: IAgentHostDatabase, + private readonly _logService: ILogService, + ) { } + + synchronize(session: URI, request: IAgentHostCatalogSyncRequest): Promise { + return this.runExclusive(session, () => this._synchronizeNow(session, request)); + } + + synchronizeWithFactory(session: URI, requestFactory: () => Promise): Promise { + return this.runExclusive(session, async () => this._synchronizeNow(session, await requestFactory())); + } + + runExclusive(session: URI, operation: () => Promise): Promise { + const sessionKey = session.toString(); + return new Promise((resolve, reject) => { + let queue = this._queues.get(sessionKey); + if (!queue) { + queue = { running: false, pending: [] }; + this._queues.set(sessionKey, queue); + } + + queue.pending.push({ + run: async () => { + try { + resolve(await operation()); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }, + }); + + if (!queue.running) { + queue.running = true; + void this._drain(sessionKey, queue); + } + }); + } + + private async _drain(sessionKey: string, queue: ISessionSyncQueue): Promise { + while (queue.pending.length > 0) { + await queue.pending.shift()!.run(); + } + queue.running = false; + this._queues.delete(sessionKey); + } + + private async _synchronizeNow(session: URI, request: IAgentHostCatalogSyncRequest): Promise { + const sessionKey = session.toString(); + const ref = this._sessionDataService.openDatabase(session); + try { + 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 this._legacyMetadataMatches(ref.object, request.legacyMetadata); + const pending = await this._storePending(ref.object, sessionKey, request, existing, legacyMetadataMatches); + return { status: 'pending', sourceRevision: pending.sourceRevision, reason: 'upsertFailed' }; + } + + const sessionGeneration = central?.sessionGeneration + ?? (existing?.state === 'pending' ? existing.sessionGeneration : generateUuid()); + const legacyMetadataMatches = await this._legacyMetadataMatches(ref.object, request.legacyMetadata); + const candidate = this._project(request.source, sessionKey, sessionGeneration, INITIAL_SOURCE_REVISION); + const sourceRevision = this._sourceRevision(existing, central, sessionGeneration, candidate.catalog.sourceHash, legacyMetadataMatches); + const projection = sourceRevision === INITIAL_SOURCE_REVISION + ? candidate + : this._project(request.source, sessionKey, sessionGeneration, sourceRevision); + const snapshot: ISessionCatalogSyncPendingSnapshot = { + sessionGeneration, + sourceRevision, + projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + payload: projection.sourcePayload, + payloadHash: projection.catalog.sourceHash, + state: 'pending', + }; + + 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' + && existing?.state === 'acknowledged' + && this._matchesReceipt(central, existing) + && legacyMetadataMatches) { + return { status: 'acknowledged', sourceRevision }; + } + } + + let upsertResult: AgentHostDatabaseSessionV2UpsertResult; + try { + upsertResult = await this._catalogDatabase.upsertSessionV2(projection.catalog, 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 projection 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', + }; + } finally { + ref.dispose(); + } + } + + private async _storePending( + database: ReturnType['object'], + session: string, + request: IAgentHostCatalogSyncRequest, + existing: ISessionCatalogSyncSnapshot | undefined, + legacyMetadataMatches: boolean, + ): Promise { + const sessionGeneration = existing?.sessionGeneration ?? generateUuid(); + const candidate = this._project(request.source, session, sessionGeneration, INITIAL_SOURCE_REVISION); + const sourceRevision = this._sourceRevision(existing, undefined, sessionGeneration, candidate.catalog.sourceHash, legacyMetadataMatches); + const projection = sourceRevision === INITIAL_SOURCE_REVISION + ? candidate + : this._project(request.source, session, sessionGeneration, sourceRevision); + const snapshot: ISessionCatalogSyncPendingSnapshot = { + sessionGeneration, + sourceRevision, + projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + payload: projection.sourcePayload, + payloadHash: projection.catalog.sourceHash, + state: 'pending', + }; + if (existing && existing.sessionGeneration !== sessionGeneration) { + await database.transitionMetadataValuesAndCatalogSyncSnapshot(request.legacyMetadata, existing.sessionGeneration, snapshot); + } else { + await database.setMetadataValuesAndCatalogSyncSnapshot(request.legacyMetadata, snapshot); + } + return snapshot; + } + + private async _legacyMetadataMatches( + 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); + } + + private _sourceRevision( + existing: ISessionCatalogSyncSnapshot | undefined, + central: IAgentHostDatabaseSessionV2 | 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_PROJECTION_VERSION + && local.payloadHash === payloadHash; + const centralMatches = current?.projectionVersion === AGENT_HOST_CATALOG_PROJECTION_VERSION + && current.sourceHash === 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 _matchesReceipt(central: IAgentHostDatabaseSessionV2 | undefined, receipt: ISessionCatalogSyncSnapshot): boolean { + return central?.sessionGeneration === receipt.sessionGeneration + && central.sourceRevision === receipt.sourceRevision + && central.projectionVersion === receipt.projectionVersion + && central.sourceHash === receipt.payloadHash; + } + + private _project(source: IAgentHostCatalogSource, session: string, sessionGeneration: string, sourceRevision: number) { + const result = projectAgentHostCatalog(source, { + session, + sessionGeneration, + sourceRevision, + }); + if (!result.ok) { + throw new Error(`Invalid catalog source at ${result.error.field}: ${result.error.message}`); + } + return result.value; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index 32ae0e953b150e..201ed67a02b580 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -5,6 +5,8 @@ import * as fs from 'fs'; import type { Database, RunResult } from '@vscode/sqlite3'; +import { Sequencer } from '../../../base/common/async.js'; +import { stableStringify } from '../../../base/common/objects.js'; import { dirname } from '../../../base/common/path.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { AgentProvider } from '../common/agent.js'; @@ -41,6 +43,51 @@ export interface IAgentHostDatabaseExternalUpdate { readonly external: boolean; } +export type AgentHostCatalogTitleSource = 'user' | 'agent' | 'auto'; +export type AgentHostCatalogChatKind = 'default' | 'peer'; + +export interface IAgentHostDatabaseCatalogChat { + readonly uri: string; + readonly order: number; + readonly kind: AgentHostCatalogChatKind; + readonly title: string | undefined; + readonly titleSource: AgentHostCatalogTitleSource | undefined; + readonly originJson: string | undefined; +} + +export interface IAgentHostDatabaseSessionV2Projection { + readonly session: string; + readonly sessionGeneration: string; + readonly modifiedTime: number; + readonly title: string | undefined; + readonly titleSource: AgentHostCatalogTitleSource | undefined; + readonly isRead: boolean; + readonly isArchived: boolean; + readonly projectUri: string | undefined; + readonly projectDisplayName: string | undefined; + readonly workspaceless: boolean; + readonly isChatBacking: boolean; + readonly ehcliAdoptable?: boolean; + readonly multiRootJson: string | undefined; + readonly folderPickerJson: string | undefined; + readonly changesSummaryJson: string | undefined; + readonly githubSummaryJson: string | undefined; + readonly gitSummaryJson: string | undefined; + readonly sourceControlSummaryJson: string | undefined; + readonly artifactsJson: string | undefined; + readonly orchestrationJson: string | undefined; + readonly sourceRevision: number; + readonly projectionVersion: number; + readonly sourceHash: string; + readonly verified: true; + readonly workingDirectoriesJson: string; + readonly chatsJson: string; +} + +export interface IAgentHostDatabaseSessionV2 extends IAgentHostDatabaseSessionV2Projection, IAgentHostDatabaseSession { } + +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 @@ -80,6 +127,9 @@ export interface IAgentHostDatabase extends IDisposable { setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise; /** Session URIs currently marked Agent-Merge-enabled. */ listAgentMergeEnabledSessions(): Promise; + getSessionV2(session: string): Promise; + listSessionsV2(): Promise; + upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise; close(): Promise; } @@ -109,6 +159,48 @@ const migrations = [ `UPDATE sessions SET registration_source = CASE WHEN external = 1 THEN 'discovery' ELSE 'explicit' END`, ].join(';\n'), }, + { + version: 4, + sql: [ + `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_v2 (session_uri, provider, start_time, external, registration_source) + SELECT session_uri, provider, start_time, external, registration_source FROM sessions`, + ].join(';\n'), + }, + { + version: 5, + sql: 'ALTER TABLE sessions_v2 ADD COLUMN is_chat_backing INTEGER NOT NULL DEFAULT 0 CHECK (is_chat_backing IN (0, 1))', + }, ] as const; function openDatabase(path: string): Promise { @@ -169,10 +261,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())); } @@ -181,103 +269,99 @@ 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, source } = sessionOptions; - const changes = await runReturningChanges( - await this._ensureDatabase(), - `INSERT INTO sessions (session_uri, provider, start_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, - 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, 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, 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, + 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, source, source, registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session)], + ); + if (!registerOptions.checkTombstone) { + await run(database, 'DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]); + } + await this._mirrorSessionV2Registry(database, 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 sessions 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 SET external = ?, registration_source = ${source} + WHERE session_uri = ? AND external IS NULL`, [external ? 1 : 0, session]); + await this._mirrorSessionV2Registry(database, session); + } + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, 'Failed to update legacy session provenance'); } - throw error; - } + }); } async listSessions(): Promise { @@ -372,8 +456,255 @@ export class AgentHostDatabase implements IAgentHostDatabase { return rows.map(row => (row.key as string).slice(agentMergeEnabledKeyPrefix.length)); } + async getSessionV2(session: string): Promise { + const row = await get( + await this._ensureDatabase(), + `SELECT sessions_v2.* + FROM sessions_v2 + INNER JOIN sessions ON sessions.session_uri = sessions_v2.session_uri + WHERE sessions_v2.session_uri = ? AND sessions_v2.verified = 1 + AND NOT EXISTS (SELECT 1 FROM metadata WHERE key = ? AND value = 'true')`, + [session, tombstoneKey(session)], + ); + return row ? this._toSessionV2(row) : undefined; + } + + async listSessionsV2(): Promise { + const rows = await all( + await this._ensureDatabase(), + `SELECT sessions_v2.* + FROM sessions_v2 + INNER JOIN sessions ON sessions.session_uri = sessions_v2.session_uri + WHERE sessions_v2.verified = 1 + AND NOT EXISTS ( + SELECT 1 FROM metadata + WHERE key = 'sessionTombstone:' || sessions_v2.session_uri AND value = 'true' + ) + ORDER BY sessions_v2.session_uri`, + [], + ); + return rows.map(row => this._toSessionV2(row)); + } + + async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { + this._validateSessionV2Projection(projection); + 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(projection.session)]); + if (tombstone?.value === 'true') { + await exec(database, 'COMMIT'); + return 'tombstoned'; + } + const registry = await get(database, 'SELECT provider, start_time, external, registration_source FROM sessions WHERE session_uri = ?', [projection.session]); + if (!registry) { + await exec(database, 'COMMIT'); + return 'missingSession'; + } + const current = await get(database, 'SELECT session_generation, source_revision, projection_version, source_hash, verified FROM sessions_v2 WHERE session_uri = ?', [projection.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 === projection.sessionGeneration) { + const currentRevision = current?.source_revision as number; + if (projection.sourceRevision < currentRevision) { + await exec(database, 'COMMIT'); + return 'stale'; + } + if (projection.sourceRevision === currentRevision) { + const replayed = current?.projection_version === projection.projectionVersion && current?.source_hash === projection.sourceHash; + await exec(database, 'COMMIT'); + return replayed ? 'replayed' : 'conflict'; + } + } + + await run(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, is_chat_backing, 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + ON CONFLICT(session_uri) DO UPDATE SET + provider = excluded.provider, + start_time = excluded.start_time, + external = excluded.external, + registration_source = excluded.registration_source, + modified_time = excluded.modified_time, + title = excluded.title, + title_source = excluded.title_source, + is_read = excluded.is_read, + is_archived = excluded.is_archived, + project_uri = excluded.project_uri, + project_display_name = excluded.project_display_name, + workspaceless = excluded.workspaceless, + is_chat_backing = excluded.is_chat_backing, + ehcli_adoptable = excluded.ehcli_adoptable, + working_directories_json = excluded.working_directories_json, + chats_json = excluded.chats_json, + multi_root_json = excluded.multi_root_json, + folder_picker_json = excluded.folder_picker_json, + changes_summary_json = excluded.changes_summary_json, + github_summary_json = excluded.github_summary_json, + git_summary_json = excluded.git_summary_json, + source_control_summary_json = excluded.source_control_summary_json, + artifacts_json = excluded.artifacts_json, + orchestration_json = excluded.orchestration_json, + session_generation = excluded.session_generation, + source_revision = excluded.source_revision, + projection_version = excluded.projection_version, + source_hash = excluded.source_hash, + verified = excluded.verified`, [ + projection.session, + registry.provider, + registry.start_time, + registry.external, + registry.registration_source, + projection.modifiedTime, + projection.title, + projection.titleSource, + projection.isRead ? 1 : 0, + projection.isArchived ? 1 : 0, + projection.projectUri, + projection.projectDisplayName, + projection.workspaceless ? 1 : 0, + projection.isChatBacking ? 1 : 0, + projection.ehcliAdoptable === undefined ? null : projection.ehcliAdoptable ? 1 : 0, + projection.workingDirectoriesJson, + projection.chatsJson, + projection.multiRootJson, + projection.folderPickerJson, + projection.changesSummaryJson, + projection.githubSummaryJson, + projection.gitSummaryJson, + projection.sourceControlSummaryJson, + projection.artifactsJson, + projection.orchestrationJson, + projection.sessionGeneration, + projection.sourceRevision, + projection.projectionVersion, + projection.sourceHash, + ]); + await exec(database, 'COMMIT'); + return 'applied'; + } catch (error) { + return this._rollback(database, error, `Failed to upsert sessions_v2 row for ${projection.session}`); + } + }); + } + + private _validateSessionV2Projection(projection: IAgentHostDatabaseSessionV2Projection): void { + for (const [name, value] of [ + ['modifiedTime', projection.modifiedTime], + ['sourceRevision', projection.sourceRevision], + ['projectionVersion', projection.projectionVersion], + ] 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', projection.session], + ['sessionGeneration', projection.sessionGeneration], + ['sourceHash', projection.sourceHash], + ['workingDirectoriesJson', projection.workingDirectoriesJson], + ['chatsJson', projection.chatsJson], + ] as const) { + if (!value) { + throw new Error(`Catalog ${name} must not be empty`); + } + } + if (projection.verified !== true) { + throw new Error('Catalog projection must be verified before it is stored'); + } + const workingDirectories = this._validateCanonicalJson('workingDirectoriesJson', projection.workingDirectoriesJson); + const chats = this._validateCanonicalJson('chatsJson', projection.chatsJson); + if (!Array.isArray(workingDirectories) || !Array.isArray(chats)) { + throw new Error('Catalog working directories and chats must be JSON arrays'); + } + for (const [name, value] of [ + ['multiRootJson', projection.multiRootJson], + ['folderPickerJson', projection.folderPickerJson], + ['changesSummaryJson', projection.changesSummaryJson], + ['githubSummaryJson', projection.githubSummaryJson], + ['gitSummaryJson', projection.gitSummaryJson], + ['sourceControlSummaryJson', projection.sourceControlSummaryJson], + ['artifactsJson', projection.artifactsJson], + ['orchestrationJson', projection.orchestrationJson], + ] as const) { + if (value !== undefined) { + this._validateCanonicalJson(name, value); + } + } + } + + private _validateCanonicalJson(name: string, value: string): unknown { + const parsed = JSON.parse(value); + if (stableStringify(parsed) !== value) { + throw new Error(`Catalog ${name} must be canonical JSON`); + } + return parsed; + } + + private _toSessionV2(row: Record): IAgentHostDatabaseSessionV2 { + return { + session: row.session_uri as string, + provider: row.provider as AgentProvider, + startTime: row.start_time as number, + external: row.external === null ? undefined : row.external === 1, + source: row.registration_source as AgentSessionRegistrationSource, + sessionGeneration: row.session_generation as string, + modifiedTime: row.modified_time as number, + title: row.title === null ? undefined : row.title as string, + titleSource: row.title_source === null ? undefined : row.title_source as AgentHostCatalogTitleSource, + isRead: row.is_read === 1, + isArchived: row.is_archived === 1, + projectUri: row.project_uri === null ? undefined : row.project_uri as string, + projectDisplayName: row.project_display_name === null ? undefined : row.project_display_name as string, + workspaceless: row.workspaceless === 1, + isChatBacking: row.is_chat_backing === 1, + ehcliAdoptable: row.ehcli_adoptable === null ? undefined : row.ehcli_adoptable === 1, + workingDirectoriesJson: row.working_directories_json as string, + chatsJson: row.chats_json as string, + multiRootJson: row.multi_root_json === null ? undefined : row.multi_root_json as string, + folderPickerJson: row.folder_picker_json === null ? undefined : row.folder_picker_json as string, + changesSummaryJson: row.changes_summary_json === null ? undefined : row.changes_summary_json as string, + githubSummaryJson: row.github_summary_json === null ? undefined : row.github_summary_json as string, + gitSummaryJson: row.git_summary_json === null ? undefined : row.git_summary_json as string, + sourceControlSummaryJson: row.source_control_summary_json === null ? undefined : row.source_control_summary_json as string, + artifactsJson: row.artifacts_json === null ? undefined : row.artifacts_json as string, + orchestrationJson: row.orchestration_json === null ? undefined : row.orchestration_json as string, + sourceRevision: row.source_revision as number, + projectionVersion: row.projection_version as number, + sourceHash: row.source_hash as string, + verified: true, + }; + } + + private _mirrorSessionV2Registry(database: Database, session: string): Promise { + return run(database, `UPDATE sessions_v2 SET + provider = (SELECT provider FROM sessions WHERE session_uri = ?1), + start_time = (SELECT start_time FROM sessions WHERE session_uri = ?1), + external = (SELECT external FROM sessions WHERE session_uri = ?1), + registration_source = (SELECT registration_source FROM sessions WHERE session_uri = ?1) + WHERE session_uri = ?1 AND EXISTS (SELECT 1 FROM sessions WHERE session_uri = ?1)`, [session]); + } + + 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 { @@ -388,6 +719,7 @@ 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; for (const migration of migrations) { diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index fff3b2b5edacf2..93d063982f54fa 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -47,6 +47,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi private readonly _pullRequestAbortController = new AbortController(); constructor( + private readonly _persistSessionMetadata: (session: string, values: Readonly>) => Promise, @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, @@ -320,7 +321,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi const sourceControlStateChanged = !objectEquals(currentSourceControlState, nextSourceControlState); if (objectEquals(currentState, nextState) && !sourceControlStateChanged) { - await this._saveSessionState(sessionKey, META_GITHUB_STATE, JSON.stringify(nextState)); + await this._saveSessionState(sessionKey, { [META_GITHUB_STATE]: JSON.stringify(nextState) }); return; } @@ -330,10 +331,13 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi this._onDidChangeSessionGitHubState.fire(sessionKey); // Update session database - await this._saveSessionState(sessionKey, META_GITHUB_STATE, JSON.stringify(nextState)); + const metadata: Record = { + [META_GITHUB_STATE]: JSON.stringify(nextState), + }; if (sourceControlStateChanged && nextSourceControlState) { - await this._saveSessionState(sessionKey, META_SOURCE_CONTROL_STATE, JSON.stringify(nextSourceControlState)); + metadata[META_SOURCE_CONTROL_STATE] = JSON.stringify(nextSourceControlState); } + await this._saveSessionState(sessionKey, metadata); } async resolveSessionBaseBranchName(sessionKey: string): Promise { @@ -380,12 +384,12 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi latestOutcome: SessionSourceControlOutcome.Merge, }; if (objectEquals(currentState, nextState)) { - await this._saveSessionState(sessionKey, META_SOURCE_CONTROL_STATE, JSON.stringify(nextState)); + await this._saveSessionState(sessionKey, { [META_SOURCE_CONTROL_STATE]: JSON.stringify(nextState) }); return; } this._stateManager.setSessionMeta(sessionKey, withSessionSourceControlState(currentMeta, nextState)); - await this._saveSessionState(sessionKey, META_SOURCE_CONTROL_STATE, JSON.stringify(nextState)); + await this._saveSessionState(sessionKey, { [META_SOURCE_CONTROL_STATE]: JSON.stringify(nextState) }); } private async _setSessionGitState(sessionKey: string, gitState: ISessionGitState): Promise { @@ -395,30 +399,20 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi this._stateManager.setSessionMeta(sessionKey, nextMeta); // Update session database - await this._saveSessionState(sessionKey, META_GIT_STATE, JSON.stringify(gitState)); + await this._saveSessionState(sessionKey, { [META_GIT_STATE]: JSON.stringify(gitState) }); } - private async _saveSessionState(sessionKey: string, key: string, value: string): Promise { + private async _saveSessionState(sessionKey: string, values: Readonly>): Promise { // Skip saving session state if the session is not materialized const state = this._stateManager.getSessionState(sessionKey); if (state?.lifecycle === SessionLifecycle.Creating) { return; } - let databaseRef; try { - databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionKey)); + await this._persistSessionMetadata(sessionKey, values); } catch (error) { - this._logService.warn(`[AgentHostGitStateService][_saveSessionState] Failed to open session database for ${sessionKey}`, error); - return; - } - - try { - await databaseRef.object.setMetadata(key, value); - } catch (error) { - this._logService.warn(`[AgentHostGitStateService][_saveSessionState] Failed to persist ${key}`, error); - } finally { - databaseRef.dispose(); + this._logService.warn(`[AgentHostGitStateService][_saveSessionState] Failed to persist ${Object.keys(values).join(', ')}`, error); } } } diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index 20a4f585b448e2..09bdec6d5cce9c 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -16,7 +16,7 @@ import { buildConversationContext, renderResponseMarkdown, truncateMiddle } from import { AgentHostStateManager } from './agentHostStateManager.js'; import type { GitHubIssueOrPullRequest, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js'; -import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; const MAX_TITLE_LENGTH = 200; const MAX_ACTIVE_AGENT_FALLBACK_TITLE_LENGTH = 40; @@ -82,6 +82,7 @@ export interface IAgentHostSessionTitleControllerOptions { readonly octoKitService?: IAgentHostOctoKitService; readonly copilotApiService?: ICopilotApiService; readonly isActiveAgentTitleGenerationEnabled?: () => boolean; + readonly persistSessionMetadata: (session: ProtocolURI, values: Readonly>) => void; } export class AgentHostSessionTitleController extends Disposable { @@ -214,29 +215,34 @@ export class AgentHostSessionTitleController extends Disposable { private _applySeedTitle(channel: ProtocolURI, independentChat: ProtocolURI | undefined, title: string): void { if (independentChat) { this._applyTitle(independentChat, title, t => this._stateManager.updateChatTitle(channel, independentChat, t)); - this._persistAutoTitleSource(channel, independentChat); } else { this._applyTitle(channel, title, t => this._stateManager.dispatchServerAction(channel, { type: ActionType.SessionTitleChanged, title: t, })); - this._persistAutoTitleSource(channel, undefined); } + this._persistAutoTitleSource(channel, independentChat); } /** 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(channel, customChatTitleMetadataKey(independentChat), title); - this._persistSessionFlag(channel, customChatTitleSourceMetadataKey(independentChat), AGENT_HOST_TITLE_SOURCE_AUTO); + this._options.persistSessionMetadata(channel, { + [customChatTitleMetadataKey(independentChat)]: title, + [customChatTitleSourceMetadataKey(independentChat)]: AGENT_HOST_TITLE_SOURCE_AUTO, + }); return; } - this._persistSessionFlag(channel, SESSION_CUSTOM_TITLE_KEY, title); - this._persistSessionFlag(channel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_AUTO); + this._options.persistSessionMetadata(channel, { + [SESSION_CUSTOM_TITLE_KEY]: title, + [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); + this._options.persistSessionMetadata(channel, { + [independentChat ? customChatTitleSourceMetadataKey(independentChat) : SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AUTO, + }); } /** The live title of the addressed independent chat or session. */ @@ -780,10 +786,6 @@ export class AgentHostSessionTitleController extends Disposable { return buildConversationContext(turns, { maxChars: MAX_TITLE_CONTEXT_CHARS, framing }); } - private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { - persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value); - } - private _isActiveAgentTitleGenerationEnabled(channel: ProtocolURI): boolean { const serverTools = this._stateManager.getSessionState(channel)?.serverTools; return serverTools diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 954cc39b78ccde..4963a39ead216a 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -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 AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_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, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, readSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_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, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; @@ -63,9 +63,14 @@ import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.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, type AgentHostTitleSource, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, 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 } from '../common/sessionArtifacts.js'; +import { parseSessionArtifacts, readSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts } from '../common/sessionArtifacts.js'; +import { AgentHostCatalogSyncService } from './agentHostCatalogSyncService.js'; +import { projectAgentHostCatalog, type AgentHostCatalogJsonValue, type IAgentHostCatalogSource } from './agentHostCatalogProjection.js'; +import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult } from './agentHostCatalogReconciliationService.js'; +import { AgentHostCatalogShadowValidator, type AgentHostCatalogReadMode, type IAgentHostCatalogShadowValidationReporter } from './agentHostCatalogShadowValidator.js'; +import { AgentHostCatalogListReader } from './agentHostCatalogListReader.js'; import { buildWorktreeFailureNotification, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js'; import { AgentHostChangesetService } from './agentHostChangesetService.js'; @@ -217,9 +222,8 @@ const SESSION_RELEASE_GRACE_MS = (() => { })(); /** - * 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. + * Downgrade-compatible session metadata for peer provider backing. A missing + * value triggers one-time migration; `[]` is the explicit empty sentinel. */ const PEER_CHATS_METADATA_KEY = 'peerChats'; const ANNOTATIONS_METADATA_KEY = 'annotations'; @@ -264,14 +268,8 @@ const DEFAULT_CHAT_PROVIDER_DATA_METADATA_KEY = 'defaultChatProviderData'; 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. + * A downgrade-compatible peer backing entry. Central catalog rows own + * membership and list metadata; `providerData` remains opaque session data. */ interface IPersistedPeerChat { readonly uri: string; @@ -279,6 +277,29 @@ interface IPersistedPeerChat { readonly origin?: ChatOrigin; } +interface ICatalogChat { + readonly uri: string; + readonly kind: 'default' | 'peer'; + readonly title?: string; + readonly origin?: ChatOrigin; +} + +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?: AgentHostCatalogJsonValue; + }[]; +} + /** * 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 @@ -354,9 +375,18 @@ export class AgentService extends Disposable implements IAgentService { */ private readonly _sessionRegistry: AgentSessionRegistry; private readonly _orchestratorDatabase: IAgentHostDatabase; + private readonly _catalogSyncService: AgentHostCatalogSyncService; + private readonly _catalogReconciliationService: AgentHostCatalogReconciliationService; + private readonly _catalogShadowValidator: AgentHostCatalogShadowValidator; + private readonly _catalogListReader: AgentHostCatalogListReader; + private readonly _catalogListRepair = this._register(new MutableDisposable()); + private readonly _catalogSyncSuppressedSessions = new Set(); + private readonly _deferredCatalogMetadataOverrides = new Map>(); + private readonly _backgroundCatalogStateWrites = new Map>>(); private readonly _providerMigrations = new Map(); private readonly _initialProviderMigrations = new Map>(); + private readonly _providerDiscoveryRegistrations = new Map>(); /** * Backing-session URIs (as strings) whose {@link CHAT_BACKING_METADATA_KEY} @@ -417,8 +447,8 @@ export class AgentService extends Disposable implements IAgentService { /** Subscriptions to provider progress events; cleared when providers change. */ private readonly _providerSubscriptions = this._register(new DisposableStore()); /** - * Per-session tail of in-flight persisted peer-chat catalog writes, keyed by - * session URI string. Read-modify-write updates to the {@link + * Per-session tail of in-flight legacy peer-backing 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. @@ -575,6 +605,9 @@ export class AgentService extends Disposable implements IAgentService { orchestratorDatabase?: IAgentHostDatabase, private readonly _now: () => number = Date.now, debugLogsEnvironment?: IAgentHostDebugLogsEnvironment, + private readonly _catalogReadMode: AgentHostCatalogReadMode = 'legacy', + catalogShadowReporter: IAgentHostCatalogShadowValidationReporter = { report: () => { } }, + catalogShadowConcurrency?: number, ) { super(); this._logService.info('AgentService initialized'); @@ -583,6 +616,7 @@ export class AgentService extends Disposable implements IAgentService { ? joinPath(resourcesDirname(this._rootConfigResource), 'agent-host.db').fsPath : ':memory:'; this._orchestratorDatabase = this._register(orchestratorDatabase ?? new AgentHostDatabase(databasePath)); + this._catalogSyncService = new AgentHostCatalogSyncService(this._sessionDataService, this._orchestratorDatabase, this._logService); this._debugLogsCollector = debugLogsEnvironment ? this._register(new AgentHostDebugLogsCollector(debugLogsEnvironment, this._logService)) : undefined; this._sessionRegistry = this._register(new AgentSessionRegistry(this._orchestratorDatabase)); this._stateManager = this._register(new AgentHostStateManager(_logService, { @@ -608,11 +642,13 @@ export class AgentService extends Disposable implements IAgentService { this._register(this._stateManager.onDidChangeSessionSummary(({ session, changes }) => { const meta = this._stateManager.getSessionSummary(session)?._meta; if (changes.modifiedAt !== undefined + && !this._sessionListReconciliationActive && this._getExternalSessionsMode() === AgentHostExternalSessionsMode.Recent && readSessionExternal(meta) && !readSessionEhcliAdoptable(meta)) { this._queueSessionListReconciliation(); } + this._queueCatalogSync(URI.parse(session), {}); })); // Build a local instantiation scope so downstream components can // consume {@link IAgentConfigurationService} (and later {@link ILogService}) @@ -701,7 +737,10 @@ export class AgentService extends Disposable implements IAgentService { this._customizationEnablementService = this._register(instantiationService.createInstance(AgentHostCustomizationEnablementService)); services.set(IAgentHostCustomizationEnablementService, this._customizationEnablementService); - this._gitStateService = this._register(instantiationService.createInstance(AgentHostGitStateService)); + this._gitStateService = this._register(instantiationService.createInstance( + AgentHostGitStateService, + (session, values) => this._persistListVisibleSessionState(URI.parse(session), values), + )); services.set(IAgentHostGitStateService, this._gitStateService); this._agentMergeController = this._register(instantiationService.createInstance(AgentMergeController, { startTurn: (session, turnId, prompt) => this._startAgentMergePrompt(session, turnId, prompt), @@ -819,10 +858,11 @@ export class AgentService extends Disposable implements IAgentService { onUserMessage: (session, text) => { void this._gitStateService.attachSessionGitHubReferences(session.toString(), text); }, + persistSessionMetadata: (session, values) => this._queueCatalogSync(URI.parse(session), values), })); this._sessionCoordination = this._register(new SessionCoordinationService( this._stateManager, - this._sessionDataService, + async (session, values) => this._persistListVisibleSessionState(URI.parse(session), values), this._logService, { getSessionMetadata: session => this._getSessionMetadata(session), @@ -841,6 +881,24 @@ export class AgentService extends Disposable implements IAgentService { session => this._agentMergeController.getTurnContext(session), ); this._serverToolHost = new AgentServerToolHost(this._stateManager, buildServerToolGroups(this._createSessionServerToolAccessor(), agentMergeTools, this._createArtifactServerToolAccessor())); + this._catalogReconciliationService = this._register(new AgentHostCatalogReconciliationService( + this._sessionDataService, + this._orchestratorDatabase, + this._catalogSyncService, + this._storageService, + () => this._listRegisteredSessions(), + registered => this._resolveCatalogReconciliationSource(registered), + this._logService, + )); + this._catalogReconciliationService.schedule(); + this._catalogShadowValidator = new AgentHostCatalogShadowValidator( + this._orchestratorDatabase, + catalogShadowReporter, + () => this._catalogReconciliationService.schedule(), + this._logService, + catalogShadowConcurrency === undefined ? {} : { concurrency: catalogShadowConcurrency }, + ); + this._catalogListReader = new AgentHostCatalogListReader(this._orchestratorDatabase); } /** @@ -852,6 +910,13 @@ export class AgentService extends Disposable implements IAgentService { return this._agents; } + async whenCatalogReconciliationIdle(): Promise { + await this._catalogReconciliationService.whenIdle(); + while (this._backgroundCatalogStateWrites.size > 0) { + await Promise.allSettled([...this._backgroundCatalogStateWrites.values()].flatMap(writes => [...writes])); + } + } + /** * Fires with the provider id whenever a turn starts. Exposed alongside * {@link agents} so {@link AgentModelRefreshScheduler} can gate its periodic @@ -1023,6 +1088,7 @@ export class AgentService extends Disposable implements IAgentService { } this._logService.info(`Registering agent provider: ${provider.id}`); this._providers.set(provider.id, provider); + this._catalogReconciliationService.schedule(); this._invalidateSessionList(); provider.setServerToolHost?.(this._serverToolHost); provider.setKnownSessionsFilter?.(sessions => this._filterKnownSessions(sessions)); @@ -1038,8 +1104,16 @@ export class AgentService extends Disposable implements IAgentService { this._providerSubscriptions.add(this._sideEffects.registerProgressListener(provider)); this._providerSubscriptions.add(provider.onDidMaterializeChat(e => this._onDidMaterializeChat(e))); this._providerSubscriptions.add(provider.onDidDiscoverChats(chats => { - void this._registerDiscoveredChats(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._registerDiscoveredChats(provider, chats, false)) + .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); + } + }); })); if (provider.onMcpNotification) { this._providerSubscriptions.add(provider.onMcpNotification(e => this._onMcpNotification.fire(e))); @@ -1157,7 +1231,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) }), }; } @@ -1246,7 +1320,7 @@ export class AgentService extends Disposable implements IAgentService { validateRenameTitle(title, SessionServerToolName.RenameChat); const isDefaultChat = isDefaultChatUri(chat.toString()); if (isDefaultChat && await this._isOnlySessionChat(session)) { - await persistSessionMetadataValues(this._sessionDataService, session.toString(), { + await this._persistOrderedListVisibleSessionState(session, { [SESSION_CUSTOM_TITLE_KEY]: title, [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_AGENT, }); @@ -1260,7 +1334,7 @@ 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 this._persistOrderedListVisibleSessionState(session, { [customChatTitleMetadataKey(chat.toString())]: title, [customChatTitleSourceMetadataKey(chat.toString())]: AGENT_HOST_TITLE_SOURCE_AGENT, }); @@ -1276,6 +1350,10 @@ export class AgentService extends Disposable implements IAgentService { if (state) { return state.chats.length === 1; } + const central = await this._readCentralChatCatalog(session); + if (central) { + return central.filter(chat => chat.kind === 'peer').length === 0; + } const persisted = await this._readPersistedPeerChatCatalog(session); return persisted?.length === 0; } @@ -1284,6 +1362,10 @@ export class AgentService extends Disposable implements IAgentService { if (this._stateManager.getSessionState(session.toString())?.chats.some(candidate => candidate.resource === chat.toString())) { return true; } + const central = await this._readCentralChatCatalog(session); + if (central) { + return central.some(candidate => candidate.kind === 'peer' && candidate.uri === chat.toString()); + } const persisted = await this._readPersistedPeerChatCatalog(session); return persisted?.some(candidate => candidate.uri === chat.toString()) === true; } @@ -1320,6 +1402,99 @@ export class AgentService extends Disposable implements IAgentService { }; } + private async _legacyRegisteredSessionMetadata(registered: IRegisteredSession): Promise { + const agent = this._providers.get(registered.provider); + if (!agent) { + return undefined; + } + const metadata = await this._registeredSessionMetadata(agent, registered.session, registered.external); + if (!metadata) { + return undefined; + } + const sanitized = { ...metadata, _meta: withSessionMultiRootMetadata(metadata._meta, undefined) }; + try { + const ref = await this._sessionDataService.tryOpenDatabase(metadata.session); + if (!ref) { + return sanitized; + } + try { + const session = metadata.session.toString(); + const changesetKeys = this._changesetCoordinator.getListMetadataKeys(session); + 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_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_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_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_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; + if (persisted.customTitle) { + updated = { ...updated, summary: persisted.customTitle }; + } + 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 orchestration = parseSessionOrchestration(persisted[AH_META_ORCHESTRATION_DB_KEY]); + if (orchestration) { + updated = { ...updated, _meta: withSessionOrchestration(updated._meta, orchestration) }; + } + 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') }; + } + const multiRoot = parseSessionMultiRootMetadata(persisted[SESSION_META_MULTI_ROOT_KEY]); + if (multiRoot) { + updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) }; + } + const artifacts = parseSessionArtifacts(persisted[SESSION_ARTIFACTS_KEY]); + 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 this._changesetCoordinator.decorateListEntry(updated, persisted as Record); + } finally { + ref.dispose(); + } + } catch (error) { + this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${metadata.session}`, error); + return sanitized; + } + } + private async _getSessionMetadata(session: URI): Promise { const registered = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); if (!registered) { @@ -1343,11 +1518,14 @@ export class AgentService extends Disposable implements IAgentService { return this._registeredSessionMetadata(agent, session, registered.external); } - private _withLiveSessionMetadata(metadata: IAgentSessionMetadata, liveSummary: SessionSummary): IAgentSessionMetadata { + private _withLiveSessionMetadata(metadata: IAgentSessionMetadata, liveSummary: SessionSummary, trustLiveMultiRoot = 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, @@ -1366,6 +1544,430 @@ export class AgentService extends Disposable implements IAgentService { }; } + private _queueCatalogSync(session: URI, metadataOverrides: Readonly>): void { + const sessionKey = session.toString(); + 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 _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, () => this._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)).map(chat => ({ + ...chat, + origin: this._toCatalogJsonValue(chat.origin), + })), + }, metadataOverrides, false)); + if (result.status === 'pending') { + this._logService.warn(`[AgentService] Catalog synchronization for ${sessionKey} remains pending: ${result.reason}`); + } + } + + private async _resolveCatalogReconciliationSource(registered: IRegisteredSession): Promise { + const agent = this._providers.get(registered.provider); + if (!agent) { + return { status: 'providerUnavailable' }; + } + const isChatBacking = await this._isChatBacking(registered.session); + const metadata = isChatBacking + ? await this._registeredSessionMetadata(agent, registered.session, registered.external) + : await this._getSessionMetadata(registered.session); + if (!metadata) { + return { status: 'providerUnavailable' }; + } + const peers = await this._readOrMigrateLegacyPeerChatCatalog(agent, registered.session); + return { + status: 'available', + request: await this._buildCatalogSyncRequest(registered.session, { + modifiedTime: metadata.modifiedTime, + title: metadata.summary, + status: metadata.status ?? SessionStatus.Idle, + 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(metadata._meta, undefined) : metadata._meta, + chats: [ + { + uri: buildDefaultChatUri(registered.session), + kind: 'default', + title: metadata.summary, + }, + ...peers.map(peer => ({ + uri: peer.uri, + kind: 'peer' as const, + origin: this._toCatalogJsonValue(peer.origin), + })), + ], + }, {}, true), + }; + } + + 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, + })); + } + + private async _buildCatalogSyncRequest(session: URI, state: ICatalogSourceState, metadataOverrides: Readonly>, preferPersistedMetadata: boolean): Promise<{ readonly source: IAgentHostCatalogSource; readonly legacyMetadata: Readonly> }> { + const metadataKeys: Record = { + [SESSION_CUSTOM_TITLE_KEY]: true, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: true, + [AH_META_IS_READ_DB_KEY]: true, + [AH_META_IS_ARCHIVED_DB_KEY]: true, + [AH_META_IS_DONE_DB_KEY]: true, + [AH_META_ORCHESTRATION_DB_KEY]: true, + [AH_META_WORKSPACELESS_DB_KEY]: true, + [SESSION_META_MULTI_ROOT_KEY]: true, + [SESSION_META_FOLDER_PICKER_KEY]: true, + [SESSION_ARTIFACTS_KEY]: true, + [META_CHANGES_SUMMARY]: true, + [CHAT_BACKING_METADATA_KEY]: true, + [WORKTREE_META_REPOSITORY_ROOT]: true, + ...GIT_DB_METADATA_KEYS, + }; + for (const chat of state.chats) { + metadataKeys[customChatTitleMetadataKey(chat.uri)] = true; + metadataKeys[customChatTitleSourceMetadataKey(chat.uri)] = true; + } + + const ref = this._sessionDataService.openDatabase(session); + let persisted: { readonly [key: string]: string | undefined }; + try { + persisted = await ref.object.getMetadataObject(metadataKeys); + } finally { + ref.dispose(); + } + const metadata = { ...persisted, ...metadataOverrides }; + const title = (preferPersistedMetadata ? metadata[SESSION_CUSTOM_TITLE_KEY] : metadataOverrides[SESSION_CUSTOM_TITLE_KEY]) ?? state.title ?? ''; + const titleSource = this._catalogTitleSource(metadata[SESSION_CUSTOM_TITLE_SOURCE_KEY]); + const persistedMultiRoot = metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined + ? parseSessionMultiRootMetadata(metadata[SESSION_META_MULTI_ROOT_KEY]) + : undefined; + const multiRoot = preferPersistedMetadata + ? (metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined ? persistedMultiRoot : readSessionMultiRootMetadata(state.meta)) + : readSessionMultiRootMetadata(state.meta) ?? persistedMultiRoot; + const persistedFolderPicker = metadata[SESSION_META_FOLDER_PICKER_KEY] !== undefined + ? parseSessionFolderPickerDecision(metadata[SESSION_META_FOLDER_PICKER_KEY]) + : undefined; + const folderPicker = preferPersistedMetadata + ? (metadata[SESSION_META_FOLDER_PICKER_KEY] !== undefined ? persistedFolderPicker : readSessionFolderPickerDecision(state.meta)) + : readSessionFolderPickerDecision(state.meta) ?? persistedFolderPicker; + const persistedArtifacts = parseSessionArtifacts(metadata[SESSION_ARTIFACTS_KEY]); + 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 persistedOrchestration = metadata[AH_META_ORCHESTRATION_DB_KEY] !== undefined + ? parseSessionOrchestration(metadata[AH_META_ORCHESTRATION_DB_KEY]) + : undefined; + const orchestration = preferPersistedMetadata + ? (metadata[AH_META_ORCHESTRATION_DB_KEY] !== undefined ? persistedOrchestration : readSessionOrchestration(state.meta)) + : (metadataOverrides[AH_META_ORCHESTRATION_DB_KEY] !== undefined ? persistedOrchestration : readSessionOrchestration(state.meta) ?? persistedOrchestration); + const persistedGitHub = metadata[META_GITHUB_STATE] !== undefined + ? this._readPersistedGitHubState(metadata[META_GITHUB_STATE]) + : undefined; + const github = preferPersistedMetadata + ? (metadata[META_GITHUB_STATE] !== undefined ? persistedGitHub : readSessionGitHubState(state.meta)) + : readSessionGitHubState(state.meta) ?? persistedGitHub; + const persistedSourceControl = metadata[META_SOURCE_CONTROL_STATE] !== undefined + ? this._readPersistedSourceControlState(metadata[META_SOURCE_CONTROL_STATE]) + : undefined; + const sourceControl = preferPersistedMetadata + ? (metadata[META_SOURCE_CONTROL_STATE] !== undefined ? persistedSourceControl : readSessionSourceControlState(state.meta)) + : readSessionSourceControlState(state.meta) ?? persistedSourceControl; + const persistedGit = metadata[META_GIT_STATE] !== undefined + ? this._readPersistedGitState(metadata[META_GIT_STATE]) + : undefined; + const git = readSessionGitState(state.meta) ?? persistedGit; + const persistedWorkspaceless = metadata[AH_META_WORKSPACELESS_DB_KEY] === 'true'; + 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 + ? metadata[AH_META_IS_READ_DB_KEY] === 'true' + : stateIsRead; + const persistedArchived = metadata[AH_META_IS_ARCHIVED_DB_KEY] ?? metadata[AH_META_IS_DONE_DB_KEY]; + const isArchived = preferPersistedMetadata && persistedArchived !== undefined + ? persistedArchived === 'true' + : (state.status & SessionStatus.IsArchived) !== 0; + const persistedChanges = metadata[META_CHANGES_SUMMARY] !== undefined + ? this._readPersistedChanges(metadata[META_CHANGES_SUMMARY]) + : undefined; + const changes = preferPersistedMetadata && metadata[META_CHANGES_SUMMARY] !== undefined ? persistedChanges : state.changes; + const worktreeProject = worktreeProjectFromRepositoryRoot(metadata[WORKTREE_META_REPOSITORY_ROOT]); + const isChatBacking = !!metadata[CHAT_BACKING_METADATA_KEY] || this._unpersistedChatBackings.has(session.toString()); + const source: IAgentHostCatalogSource = { + modifiedTime: state.modifiedTime, + title: title || undefined, + titleSource, + isRead, + isArchived, + project: worktreeProject + ? { uri: worktreeProject.uri.toString(), displayName: worktreeProject.displayName } + : state.project, + workspaceless, + isChatBacking, + ehcliAdoptable: readSessionEhcliAdoptable(state.meta), + multiRoot, + folderPicker, + changes, + github, + git, + sourceControl, + artifacts, + orchestration, + workingDirectories: state.workingDirectories, + chats: state.chats.map((chat, order) => ({ + uri: chat.uri, + order, + kind: chat.kind, + title: (preferPersistedMetadata ? metadata[customChatTitleMetadataKey(chat.uri)] : metadataOverrides[customChatTitleMetadataKey(chat.uri)]) || chat.title || undefined, + titleSource: this._catalogTitleSource(metadata[customChatTitleSourceMetadataKey(chat.uri)]), + origin: chat.origin, + })), + }; + const legacyMetadata: Record = { + ...metadataOverrides, + [AH_META_IS_READ_DB_KEY]: source.isRead ? 'true' : '', + [AH_META_IS_ARCHIVED_DB_KEY]: source.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), + [AH_META_ORCHESTRATION_DB_KEY]: orchestration ? JSON.stringify(orchestration) : '', + }; + if (source.workspaceless || metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { + legacyMetadata[AH_META_WORKSPACELESS_DB_KEY] = source.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 (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 { source, legacyMetadata }; + } + + private _catalogTitleSource(value: string | undefined): AgentHostTitleSource { + return value === 'user' || value === 'agent' || value === 'auto' ? value : AGENT_HOST_TITLE_SOURCE_AUTO; + } + + private _readPersistedGitHubState(value: string | undefined): ISessionGitHubState | undefined { + if (!value) { + return undefined; + } + try { + return readSessionGitHubState({ [SESSION_META_GITHUB_KEY]: JSON.parse(value) }); + } catch { + return undefined; + } + } + + private _readPersistedSourceControlState(value: string | undefined): ISessionSourceControlState | undefined { + if (!value) { + return undefined; + } + try { + return readSessionSourceControlState({ [SESSION_META_SOURCE_CONTROL_KEY]: JSON.parse(value) }); + } catch { + return undefined; + } + } + + private _readPersistedGitState(value: string | undefined): ISessionGitState | undefined { + if (!value) { + return undefined; + } + try { + const projected = projectAgentHostCatalog({ + modifiedTime: 0, + isRead: false, + isArchived: false, + workspaceless: false, + git: JSON.parse(value), + workingDirectories: [], + chats: [], + }, { + session: 'agent-host-catalog-git-validation', + sessionGeneration: 'agent-host-catalog-git-validation', + sourceRevision: 0, + }); + return projected.ok ? projected.value.source.git : undefined; + } catch { + return undefined; + } + } + + private _readPersistedChanges(value: string | undefined): ChangesSummary | undefined { + if (!value) { + return undefined; + } + try { + return JSON.parse(value) as ChangesSummary; + } catch { + return undefined; + } + } + + private _toCatalogJsonValue(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 = this._toCatalogJsonValue(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 = this._toCatalogJsonValue(entry); + if (converted !== undefined) { + result[key] = converted; + } + } + return result; + } + return undefined; + } + + private _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; + } + private _agentMergeRestore: Promise = Promise.resolve(); private _agentMergeIndexWrites: Promise = Promise.resolve(); @@ -1500,6 +2102,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.warn(`[AgentService] initial provider catalog for ${provider.id} was unavailable; retrying before accessing sessions`, err); await this._replaceFailedInitialProviderMigration(provider, migration); } + await this._providerDiscoveryRegistrations.get(provider.id); } private _replaceFailedInitialProviderMigration(provider: IAgent, failed: Promise): Promise { @@ -1603,7 +2206,7 @@ export class AgentService extends Disposable implements IAgentService { * next readiness signal retries. */ - private async _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise { + private async _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[], awaitReconciliation = true): Promise { const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external])); const discoveryLimiter = new Limiter(4); let suppressed = 0; @@ -1632,7 +2235,10 @@ export class AgentService extends Disposable implements IAgentService { if (registered) { registryChanged = true; if (external && existing.get(session.toString()) !== true) { - await this._initializeExternalSessionReadState(session); + await this._initializeExternalSessionReadState({ + ...sessionMetadata, + _meta: withSessionMultiRootMetadata(sessionMetadata._meta, undefined), + }); } existing.set(session.toString(), external); if (external && !readSessionEhcliAdoptable(sessionMetadata._meta)) { @@ -1655,6 +2261,9 @@ export class AgentService extends Disposable implements IAgentService { } if (registeredExternal) { this._queueSessionListReconciliation(); + if (awaitReconciliation) { + await this._sessionListReconciliation; + } } this._logService.info(`[AgentService] discovery for provider ${provider.id}: ${chats.length} candidate(s) (${chats.filter(chat => chat.external).length} external), ${registered} registered, ${alreadyRegistered} already registered, ${suppressed} suppressed as subagent/chat backing`); return registered > 0; @@ -1694,7 +2303,10 @@ export class AgentService extends Disposable implements IAgentService { this._invalidateSessionList(); const metadata = sessions[index]; if (identity.external && existing.get(identity.session.toString()) !== true) { - await this._initializeExternalSessionReadState(identity.session); + await this._initializeExternalSessionReadState({ + ...metadata, + _meta: withSessionMultiRootMetadata(metadata._meta, undefined), + }); } existing.set(identity.session.toString(), identity.external); if (identity.external && !readSessionEhcliAdoptable(metadata._meta)) { @@ -1710,14 +2322,21 @@ export class AgentService extends Disposable implements IAgentService { } } - /** 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); - try { - await ref.object.setMetadata(AH_META_IS_READ_DB_KEY, 'true'); - } finally { - ref.dispose(); - } + private async _initializeExternalSessionReadState(metadata: IAgentSessionMetadata): Promise { + await this._catalogSyncService.synchronizeWithFactory(metadata.session, () => this._buildCatalogSyncRequest(metadata.session, { + modifiedTime: metadata.modifiedTime, + title: metadata.summary, + status: (metadata.status ?? SessionStatus.Idle) | SessionStatus.IsRead, + project: metadata.project ? { uri: metadata.project.uri.toString(), displayName: metadata.project.displayName } : undefined, + workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()) ?? [], + changes: metadata.changes, + meta: metadata._meta, + chats: [{ + uri: buildDefaultChatUri(metadata.session), + kind: 'default', + title: metadata.summary, + }], + }, { [AH_META_IS_READ_DB_KEY]: 'true' }, true)); } private async _isExternalProviderChat(session: URI): Promise { @@ -1745,8 +2364,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 _retryRegistryMutation(operation: () => Promise, description: string): Promise { @@ -1838,138 +2468,52 @@ export class AgentService extends Disposable implements IAgentService { // longer evicts a session. const registered = await this._listRegisteredSessions(); const metadataLimiter = new Limiter(4); + let repairNeeded = false; const results = await Promise.all(registered.map(registeredSession => metadataLimiter.queue(async (): Promise => { - const { session, provider, external } = registeredSession; + const { session } = 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())) { return undefined; } - - const agent = this._providers.get(provider); - if (!agent) { + if (this._unpersistedChatBackings.has(session.toString())) { return undefined; } + + if (this._catalogReadMode === 'centralWithFallback' || this._catalogReadMode === 'central') { + const central = await this._catalogListReader.read(registeredSession); + if (central.eligible) { + return central.metadata; + } + if (central.reason === 'chatBacking') { + return undefined; + } + repairNeeded = true; + if (central.reason === 'readError') { + 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.reason}`); + } + if (this._catalogReadMode === 'central') { + return undefined; + } + } + try { - return await this._registeredSessionMetadata(agent, session, external); + return await this._legacyRegisteredSessionMetadata(registeredSession); } catch (err) { this._logService.warn(`[AgentService] listSessions: failed to read metadata for ${session}`, err); return undefined; } }))); - const flat = results.filter((s): s is IAgentSessionMetadata => s !== undefined); - - // Overlay persisted custom titles from per-session databases. - const overlayLimiter = new Limiter(4); - 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_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_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_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_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 orchestration = parseSessionOrchestration(m[AH_META_ORCHESTRATION_DB_KEY]); - if (orchestration) { - updated = { ...updated, _meta: withSessionOrchestration(updated._meta, orchestration) }; - } - 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') }; - } - const multiRoot = parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY]); - if (multiRoot) { - updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) }; - } - const artifacts = parseSessionArtifacts(m[SESSION_ARTIFACTS_KEY]); - 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); + if (repairNeeded) { + this._catalogListRepair.value = disposableTimeout(() => { + this._catalogListRepair.clear(); + this._catalogReconciliationService.start(); + }, 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 @@ -1984,7 +2528,7 @@ export class AgentService extends Disposable implements IAgentService { const withStatus = result.map(s => { const liveSummary = this._stateManager.getSessionSummary(s.session.toString()); if (liveSummary) { - return this._withLiveSessionMetadata(s, liveSummary); + return this._withLiveSessionMetadata(s, liveSummary, false); } return s; }); @@ -2013,6 +2557,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), @@ -2029,7 +2576,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; @@ -2049,7 +2596,7 @@ export class AgentService extends Disposable implements IAgentService { } this._logHiddenSessions(hiddenByExternalMode, combined.length, 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 ${combined.length} session(s) for mode '${mode}' in ${duration}ms (${additions.length} state-manager fallback)`; if (duration >= SLOW_LIST_SESSIONS_THRESHOLD_MS) { @@ -2060,6 +2607,9 @@ export class AgentService extends Disposable implements IAgentService { if (epoch !== this._registryEpoch) { return this.listSessions(mode); } + if (this._catalogReadMode === 'shadow') { + this._catalogShadowValidator.schedule(visible, registered); + } return visible; } @@ -2160,6 +2710,8 @@ export class AgentService extends Disposable implements IAgentService { private readonly _announcedSurfacedKeys = new Set(); private readonly _broadcastExternalSessions = new Set(); private _sessionListReconciliation = Promise.resolve(); + private _sessionListReconciliationActive = false; + private readonly _sessionListReconciliationRequests: Array = []; /** Tracks the migrate-legacy setting so the config listener acts only on transitions. */ private _lastMigrateLegacyEnabled = false; @@ -2232,9 +2784,25 @@ export class AgentService extends Disposable implements IAgentService { } private _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode): void { - this._sessionListReconciliation = this._sessionListReconciliation - .then(() => this._reconcileExternalSessions(previousMode)) - .catch(error => this._logService.warn('[AgentService] External session reconciliation failed', error)); + this._sessionListReconciliationRequests.push(previousMode); + if (this._sessionListReconciliationActive) { + return; + } + this._sessionListReconciliation = (async () => { + this._sessionListReconciliationActive = true; + try { + while (this._sessionListReconciliationRequests.length > 0) { + const requestedPreviousMode = this._sessionListReconciliationRequests.shift(); + try { + await this._reconcileExternalSessions(requestedPreviousMode); + } catch (error) { + this._logService.warn('[AgentService] External session reconciliation failed', error); + } + } + } finally { + this._sessionListReconciliationActive = false; + } + })(); } private async _reconcileExternalSessions(previousMode?: AgentHostExternalSessionsMode): Promise { @@ -2651,13 +3219,13 @@ export class AgentService extends Disposable implements IAgentService { } this._changesetCoordinator.onSessionCreated(session.toString()); + const workingDirectory = created.resolvedWorkingDirectory ?? config?.workingDirectories?.[0]; + void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory); 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)); + await this._persistOrderedListVisibleSessionState(session, this._creationMetadataOverrides(this._stateManager.getSessionState(session.toString())?._meta)); // `SessionReady` means the agent has a live SDK session. Provisional // sessions defer it to {@link _onDidMaterializeChat}. @@ -2668,9 +3236,6 @@ export class AgentService extends Disposable implements IAgentService { } } - const workingDirectory = created.resolvedWorkingDirectory ?? config?.workingDirectories?.[0]; - void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory); - return session; } @@ -2764,23 +3329,64 @@ export class AgentService extends Disposable implements IAgentService { // 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)); + 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 catalogChats = [ + ...existingCatalogChats, + { + uri: chat.toString(), + kind: 'peer' as const, + ...(title !== undefined ? { title } : {}), + ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), + }, + ]; + this._catalogSyncSuppressedSessions.add(sessionKey); try { await this._persistPeerChat(session, chat, providerData, peerChatOrigin); + await this._persistOrderedListVisibleSessionState(session, title === undefined ? {} : { + [customChatTitleMetadataKey(chat.toString())]: title, + [customChatTitleSourceMetadataKey(chat.toString())]: AGENT_HOST_TITLE_SOURCE_AUTO, + }, catalogChats); } catch (error) { + this._catalogSyncSuppressedSessions.delete(sessionKey); + this._flushDeferredCatalogMetadataOverrides(session); + let catalogRollbackError: Error | undefined; + try { + await this._removePersistedPeerChat(session, chat); + } catch (rollbackError) { + catalogRollbackError = rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)); + } try { 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 AggregateError([error, ...(catalogRollbackError ? [catalogRollbackError] : []), rollbackError], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(error)}`); + } + if (catalogRollbackError) { + throw new AggregateError([error, catalogRollbackError], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(error)}`); } 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 } : {}), - }); + try { + 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 } : {}), + }); + } finally { + this._catalogSyncSuppressedSessions.delete(sessionKey); + this._flushDeferredCatalogMetadataOverrides(session); + } // If the agent exposes this chat as its own SDK session, mark that // backing so it stays out of the top-level session list. `_markChatBacking` @@ -2867,17 +3473,32 @@ export class AgentService extends Disposable implements IAgentService { const chatKey = chat.toString(); const provider = this._findProviderForSession(session); this._disposingPeerChats.add(chatKey); + this._catalogSyncSuppressedSessions.add(sessionKey); try { await this._checkpointService.discardChatTurnStartCheckpoints(session, chat); if (provider) { await this._disposeChat(provider, chat); } await this._removePersistedPeerChat(session, chat); + await this._clearChatDraft(session, 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), + ); + } this._sideEffects.clearQueuedMessageSenders(chatKey); this._sideEffects.cancelSubagentSessions(chatKey); this._sideEffects.clearChannelTelemetry(chatKey); this._stateManager.removeChat(sessionKey, chatKey); } finally { + this._catalogSyncSuppressedSessions.delete(sessionKey); + this._flushDeferredCatalogMetadataOverrides(session); this._disposingPeerChats.delete(chatKey); } } @@ -3386,9 +4007,7 @@ export class AgentService extends Disposable implements IAgentService { } // 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. @@ -3455,62 +4074,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 { @@ -4721,7 +5297,7 @@ export class AgentService extends Disposable implements IAgentService { // after every required step succeeds, and any failure after a successful // adoption is surfaced as a migration failure. try { - const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', catalogReadable, !!registeredSession); + const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', catalogReadable, !!registeredSession, adoption.listVisible); await this._restoreAnnotations(session); if (adopted) { this._reportLegacyMigration(agent.id, 'migrated', migrationStartTime, facts); @@ -4818,7 +5394,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'], catalogReadable: boolean, sessionKnownToRegistry: boolean): 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'], catalogReadable: boolean, sessionKnownToRegistry: boolean, 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}`); + } let meta = await this._getSessionMetadataForRestore(agent, session, external); if (!meta) { // Authoritative absence only when the catalog was readable this run and @@ -5007,6 +5586,12 @@ export class AgentService extends Disposable implements IAgentService { // Best-effort: fall back to agent-provided metadata } } + 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; @@ -5053,8 +5638,20 @@ export class AgentService extends Disposable implements IAgentService { // up-front tombstone would, before any state-manager mutation. throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); } + const centralChatCatalog = await this._readCentralChatCatalog(session); 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._serverToolHost.advertise(sessionStr); // A freshly-adopted legacy session bridges its git checkpoints into the @@ -5076,7 +5673,7 @@ export class AgentService extends Disposable implements IAgentService { // Register persisted peer-chat catalog metadata. Their provider backings // and histories are restored when a peer chat is first requested. - promises.push(this._restorePeerChats(agent, session)); + promises.push(this._restorePeerChats(agent, session, centralChatCatalog ?? false)); // Register the static changeset URIs and reseed them from any // persisted file lists in the batched metadata read. The catalogue @@ -5142,58 +5739,76 @@ 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 persistent peer membership from the verified central catalog and + * merges cooling-period legacy membership before updating the projection. */ - private async _restorePeerChats(agent: IAgent, session: URI): Promise { + private async _restorePeerChats(agent: IAgent, session: URI, centralChatCatalog?: readonly ICatalogChat[] | false): Promise { + const central = centralChatCatalog === false ? undefined : centralChatCatalog ?? await this._readCentralChatCatalog(session); + if (central) { + const persisted = await this._readPersistedPeerChatCatalog(session, true); + if (persisted === undefined) { + await this._migrateLegacyPeerChats(agent, session); + } else { + await this._restorePeerChatsFromCatalog(session, persisted); + } + await this._persistOrderedListVisibleSessionState(session, {}); + return; + } const persisted = await this._readPersistedPeerChatCatalog(session); if (persisted !== undefined) { - // The orchestrator owns the catalog: enumerate from it. await this._restorePeerChatsFromCatalog(session, persisted); + await this._persistOrderedListVisibleSessionState(session, {}); return; } - // No orchestrator catalog yet: one-time migration from legacy `*.chats`. await this._migrateLegacyPeerChats(agent, session); + await this._persistOrderedListVisibleSessionState(session, {}); + } + + private async _readCentralChatCatalog(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.reason === 'readError') { + 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.reason}`); + } + return undefined; + } + return result.source.chats.map(chat => ({ + uri: chat.uri, + kind: chat.kind, + title: chat.title, + origin: this._fromCatalogChatOrigin(chat.origin), + })); } /** - * One-time migration for sessions persisted before the orchestrator owned - * the peer-chat catalog: enumerate the agent's legacy `*.chats` + * One-time migration for sessions without central chat membership: 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. + * central catalog, then retain {@link PEER_CHATS_METADATA_KEY} for cooling. */ 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; + const entries = await this._readOrMigrateLegacyPeerChatCatalog(agent, session); + await this._restorePeerChatsFromCatalog(session, entries); + } + + private async _readOrMigrateLegacyPeerChatCatalog(agent: IAgent, session: URI): Promise { + const persisted = await this._readPersistedPeerChatCatalog(session); + if (persisted !== undefined) { + return persisted; } + const legacy = await agent.listLegacyChatBackings?.(session) ?? []; 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]); + return entries; } /** @@ -5244,25 +5859,35 @@ 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._findProviderForSession(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._readPersistedPeerChatBacking(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; } } + private async _readPersistedPeerChatBacking(session: URI, chat: URI): Promise { + const entries = await this._readPersistedPeerChatCatalog(session); + return entries?.find(entry => entry.uri === chat.toString()); + } + /** * Re-persists a peer chat's opaque `providerData` blob when the agent * reports it changed (e.g. per-chat model switch or fork remap). @@ -5439,44 +6064,79 @@ export class AgentService extends Disposable implements IAgentService { } /** - * 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. + * Reads downgrade-compatible peer backing metadata. Missing returns + * `undefined`, `[]` is the explicit empty sentinel, and malformed data is + * treated as absent so migration can rebuild it. */ - private async _readPersistedPeerChatCatalog(session: URI): Promise { + private async _readPersistedPeerChatCatalog(session: URI, batched = false): Promise { const ref = await this._sessionDataService.tryOpenDatabase?.(session); if (!ref) { return undefined; } try { - const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); + 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; } - 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 } : {}), - })); + return this._parsePersistedPeerChatCatalog(session, raw); } catch (err) { - this._logService.warn(`[AgentService] Failed to read peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); + this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); return undefined; } finally { ref.dispose(); } } + private _parsePersistedPeerChatCatalog(session: URI, raw: string): IPersistedPeerChat[] { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error('expected an array'); + } + const sessionKey = session.toString(); + const seen = new Set(); + const result: IPersistedPeerChat[] = []; + for (let index = 0; index < parsed.length; 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; + } + const originValue = this._toCatalogJsonValue(value.origin); + const origin = this._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 } : {}), + }); + } + return result; + } + /** * Marks a chat's backing SDK session so legacy discovery cannot register * it as a standalone top-level session. Best-effort and never throws: @@ -5501,11 +6161,13 @@ export class AgentService extends Disposable implements IAgentService { try { await write(); this._unpersistedChatBackings.delete(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); + 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); @@ -5514,8 +6176,8 @@ 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 + * Inserts or updates a peer's downgrade-compatible backing metadata, + * 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 @@ -5538,8 +6200,7 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Removes a peer chat from the orchestrator's persisted catalog. Serialized - * per session via {@link _enqueuePeerChatCatalogWrite}. + * Removes a peer chat from downgrade-compatible backing metadata. */ private _removePersistedPeerChat(session: URI, chat: URI): Promise { const chatUri = chat.toString(); @@ -5577,21 +6238,12 @@ export class AgentService extends Disposable implements IAgentService { 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 } : {}), - })); - } + current = this._parsePersistedPeerChatCatalog(session, raw); } } catch (err) { this._logService.warn(`[AgentService] Replacing malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); } - const updated = mutate(current); + const updated = this._parsePersistedPeerChatCatalog(session, JSON.stringify(mutate(current))); await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated)); } finally { ref.dispose(); @@ -5625,6 +6277,18 @@ export class AgentService extends Disposable implements IAgentService { } } + private async _clearChatDraft(session: URI, chatUri: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return; + } + try { + await ref.object.setChatDraft(chatUri, undefined); + } finally { + ref.dispose(); + } + } + private async _getSessionMetadataForRestore(agent: IAgent, session: URI, external: boolean): Promise { const sessionStr = session.toString(); const chat = URI.parse(buildDefaultChatUri(session)); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 5b6fa6bb4d09b3..ca03d38d51155b 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -90,7 +90,7 @@ import { SessionPermissionManager } from './sessionPermissions.js'; import type { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import type { ICopilotApiService } from './shared/copilotApiService.js'; import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/proxyChatError.js'; -import { AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; import { targetForMcpServer, targetForPlugin } from './shared/customizationEnablementGate.js'; import type { WorktreeIsolation } from './shared/worktreeIsolation.js'; @@ -112,6 +112,7 @@ export interface IAgentSideEffectsOptions { readonly getGitHubToken?: () => string | undefined; /** Get the configured GitHub host used to validate issue and pull request URLs. */ readonly getGitHubHost?: () => string | undefined; + readonly persistSessionMetadata?: (session: ProtocolURI, values: Readonly>) => void; /** GitHub REST client used to fetch issue and pull request context. */ readonly octoKitService?: IAgentHostOctoKitService; /** CAPI service used for Copilot utility title generation. */ @@ -303,6 +304,7 @@ export class AgentSideEffects extends Disposable { octoKitService: this._options.octoKitService, copilotApiService: this._options.copilotApiService, isActiveAgentTitleGenerationEnabled: () => this._agentConfigService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey) === true, + persistSessionMetadata: (session, values) => this._persistSessionMetadata(session, values), })); this._localCommands = this._register(instantiationService.createInstance( AgentHostLocalCommands, @@ -313,6 +315,7 @@ export class AgentSideEffects extends Disposable { // turn back here once it has completed a host-handled command. (turnChannel: ProtocolURI) => this._tryConsumeNextQueuedMessage(turnChannel), (session: ProtocolURI, chat?: ProtocolURI) => this._titleController.markTitleRenamed(session, chat), + (session: ProtocolURI, values: Readonly>) => this._persistSessionMetadata(session, values), )); this._register(this._stateManager.onDidChangeSessionConfig(e => { const previousMode = getConfiguredSessionMode(e.previous); @@ -1708,13 +1711,17 @@ export class AgentSideEffects extends Disposable { // not the whole session. Route it to a per-chat title update so // the session title stays independent. this._stateManager.updateChatTitle(sessionChannel, chatChannel, action.title); - this._persistSessionFlag(sessionChannel, customChatTitleMetadataKey(chatChannel), action.title); - this._persistSessionFlag(sessionChannel, customChatTitleSourceMetadataKey(chatChannel), AGENT_HOST_TITLE_SOURCE_USER); + this._persistSessionMetadata(sessionChannel, { + [customChatTitleMetadataKey(chatChannel)]: action.title, + [customChatTitleSourceMetadataKey(chatChannel)]: AGENT_HOST_TITLE_SOURCE_USER, + }); this._titleController.markTitleRenamed(sessionChannel, chatChannel); break; } - this._persistSessionFlag(channel, SESSION_CUSTOM_TITLE_KEY, action.title); - this._persistSessionFlag(channel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_USER); + this._persistSessionMetadata(channel, { + [SESSION_CUSTOM_TITLE_KEY]: action.title, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_USER, + }); this._titleController.markTitleRenamed(channel); break; } @@ -1908,7 +1915,14 @@ export class AgentSideEffects extends Disposable { * title, isRead/isArchived flags, merged config values). */ private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { - persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value); + this._persistSessionMetadata(session, { [key]: value }); + } + + private _persistSessionMetadata(session: ProtocolURI, values: Readonly>): void { + if (!this._options.persistSessionMetadata) { + throw new Error('AgentSideEffects requires session metadata persistence for durable list-visible mutations'); + } + this._options.persistSessionMetadata(session, values); } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 2c709c0e36906a..64118bc0ad6cdc 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -57,7 +57,7 @@ import type { ErrorInfo } from '../../common/state/protocol/common/state.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type CustomizationEnablement, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, AuthRequiredReason, type AuthRequiredParams, type SessionAction } from '../../common/state/sessionActions.js'; import { areAdditionalWorkingDirectoriesEqual } from '../../common/state/sessionWorkingDirectories.js'; -import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_READ_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { getByokLmAgentModelId, resolveByokLmEnablement } from '../../common/agentHostByokLm.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; import { ActiveClientToolSet, structuralToolsEqual } from '../activeClientState.js'; @@ -3131,9 +3131,16 @@ export class CopilotAgent extends Disposable implements IAgent { // `isolation: 'folder'` keeps the session in place in the reused cwd — // a git repo would otherwise default to worktree and show a spurious // "Creating worktree…". - await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, customTitle, /* markRead */ true); + await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }); await this._adoptLegacyTurnUsage(session, sessionId); - return { adopted: true, eligible: true }; + const listVisible = customTitle !== undefined + ? { title: customTitle, titleSource: 'user' as const, isRead: true } + : { isRead: true }; + return { + adopted: true, + eligible: true, + listVisible, + }; }); } @@ -4736,7 +4743,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): 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): Promise { const dbRef = this._sessionDataService.openDatabase(session); const db = dbRef.object; try { @@ -4744,10 +4751,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')); - } if (workingDirectory) { work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString())); } @@ -4775,12 +4778,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 11e28e6c0941bf..b8a68133c5da90 100644 --- a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts @@ -6,13 +6,11 @@ import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { ILogService } from '../../../log/common/log.js'; -import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, StateAction } from '../../common/state/sessionActions.js'; import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ResponsePartKind, ToolCallStatus, ToolResultContentType, type ISessionWithDefaultChat, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; import { AgentHostLocalTurns } from '../agentHostLocalTurns.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { AgentHostStateManager } from '../agentHostStateManager.js'; -import { persistSessionMetadata } from '../shared/persistSessionMetadata.js'; /** * A just-started chat turn offered to the local-command dispatcher before it is @@ -43,8 +41,8 @@ export interface ILocalChatCommandContext { getState(channel: ProtocolURI): ISessionWithDefaultChat | undefined; /** Rename a single chat (independently of the session title). */ updateChatTitle(session: ProtocolURI, chat: ProtocolURI, title: string): void; - /** Persist a session-metadata key/value pair (e.g. a custom title). */ - persistSessionFlag(session: ProtocolURI, key: string, value: string): void; + /** Persist a coordinated set of session metadata values. */ + persistSessionMetadata(session: ProtocolURI, values: Readonly>): void; /** Suppress automatic naming after a local user rename. */ markTitleRenamed(session: ProtocolURI, chat?: ProtocolURI): void; } @@ -141,9 +139,9 @@ export class AgentHostLocalCommands extends Disposable { */ private readonly _notifyTurnConsumable: (turnChannel: ProtocolURI) => void, private readonly _markTitleRenamed: (session: ProtocolURI, chat?: ProtocolURI) => void, + private readonly _persistSessionMetadata: (session: ProtocolURI, values: Readonly>) => void, @ILogService private readonly _logService: ILogService, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, - @ISessionDataService private readonly _sessionDataService: ISessionDataService, ) { super(); const context: ILocalChatCommandContext = { @@ -152,7 +150,7 @@ export class AgentHostLocalCommands extends Disposable { dispatch: (channel, action) => this._stateManager.dispatchServerAction(channel, action), 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), + persistSessionMetadata: (session, values) => this._persistSessionMetadata(session, values), markTitleRenamed: (session, chat) => this._markTitleRenamed(session, chat), }; 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 d3e4ea5cca2f1e..930a26df817a54 100644 --- a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts @@ -44,19 +44,20 @@ export class RenameLocalCommand extends Disposable implements ILocalChatCommand const chatTarget = isAdditional(channel) ? channel : undefined; const sessionChannel = isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel; if (chatTarget) { + this._context.persistSessionMetadata(sessionChannel, { + [customChatTitleMetadataKey(chatTarget)]: title, + [customChatTitleSourceMetadataKey(chatTarget)]: AGENT_HOST_TITLE_SOURCE_USER, + }); // Rename only this chat, independently of the session title. this._context.updateChatTitle(sessionChannel, chatTarget, title); this._context.markTitleRenamed(sessionChannel, chatTarget); - this._context.persistSessionFlag(sessionChannel, customChatTitleMetadataKey(chatTarget), title); - this._context.persistSessionFlag(sessionChannel, customChatTitleSourceMetadataKey(chatTarget), AGENT_HOST_TITLE_SOURCE_USER); } else { + this._context.persistSessionMetadata(sessionChannel, { + [SESSION_CUSTOM_TITLE_KEY]: title, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_USER, + }); this._context.dispatch(sessionChannel, { type: ActionType.SessionTitleChanged, title }); this._context.markTitleRenamed(sessionChannel); - // Server-dispatched actions bypass `handleAction`, so persist the - // new title here directly (the client-dispatched rename path relies - // on the `SessionTitleChanged` case in `handleAction` instead). - this._context.persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_KEY, title); - this._context.persistSessionFlag(sessionChannel, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_USER); } // Acknowledge the rename with a brief response so the turn has visible // content in the transcript. diff --git a/src/vs/platform/agentHost/node/sessionCoordination.ts b/src/vs/platform/agentHost/node/sessionCoordination.ts index eb12cf10a3da63..ce654f7290d8fb 100644 --- a/src/vs/platform/agentHost/node/sessionCoordination.ts +++ b/src/vs/platform/agentHost/node/sessionCoordination.ts @@ -8,12 +8,10 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; -import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, type ChatTurnStartedAction } from '../common/state/sessionActions.js'; import { MessageKind, PendingMessageKind, AH_META_ORCHESTRATION_DB_KEY, buildDefaultChatUri, readSessionOrchestration, type ISessionOrchestration, SessionStatus, withSessionOrchestration } from '../common/state/sessionState.js'; import { type Message } from '../common/state/protocol/state.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; -import { persistSessionMetadataValues } from './shared/persistSessionMetadata.js'; export interface ISessionCoordinationTransition { readonly orchestration?: ISessionOrchestration; @@ -64,7 +62,7 @@ export class SessionCoordinationService extends Disposable { constructor( private readonly _stateManager: AgentHostStateManager, - private readonly _sessionDataService: ISessionDataService, + private readonly _persistSessionMetadata: (session: string, values: Readonly>) => Promise, private readonly _logService: ILogService, private readonly _delegate: ISessionCoordinationDelegate, ) { @@ -73,7 +71,7 @@ export class SessionCoordinationService extends Disposable { } async setOrchestration(session: string, orchestration: ISessionOrchestration): Promise { - await persistSessionMetadataValues(this._sessionDataService, session, { + await this._persistSessionMetadata(session, { [AH_META_ORCHESTRATION_DB_KEY]: JSON.stringify(orchestration), }); this._stateManager.setSessionMeta(session, withSessionOrchestration(this._stateManager.getSessionSummary(session)?._meta, orchestration)); diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index b476bd8bb58be2..ac76f5e708cfc2 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'; @@ -135,6 +135,24 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ usage TEXT NOT NULL )`, }, + { + version: 10, + sql: `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) + )`, + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -197,6 +215,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 @@ -700,6 +782,133 @@ 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; + } + }); + })); + } + + 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 85aca98b3b31b8..61dfec715877a2 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -8,13 +8,14 @@ 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 { Message } from '../../common/state/sessionState.js'; 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(); @@ -89,6 +90,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 setChatDraft(chat: URI, draft: Message | undefined): Promise { const key = chat.toString(); if (draft) { @@ -185,6 +261,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..3ffc53982a16c8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '../../common/sessionArtifacts.js'; +import { isSessionStatusArchived, isSessionStatusRead, readSessionEhcliAdoptable, readSessionExternal, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionOrchestration, readSessionSourceControlState, readSessionWorkspaceless } from '../../common/state/sessionState.js'; +import { AgentHostCatalogListReader } from '../../node/agentHostCatalogListReader.js'; +import { AGENT_HOST_CATALOG_PROJECTION_VERSION, projectAgentHostCatalog, type IAgentHostCatalogSource } 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, + external: true, + source: 'discovery', + }; + const source: IAgentHostCatalogSource = { + modifiedTime: 200, + title: 'Catalog title', + titleSource: 'user', + isRead: true, + isArchived: true, + project: { uri: 'file:///workspace', displayName: 'Workspace' }, + workspaceless: true, + ehcliAdoptable: true, + multiRoot: { workspaceFile: 'file:///workspace/project.code-workspace' }, + folderPicker: { hidden: true, primary: 'file:///workspace' }, + changes: { additions: 4, deletions: 2, files: 3 }, + github: { owner: 'microsoft', repo: 'vscode', pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'] }, + git: { + hasGitHubRemote: true, + branchName: 'feature', + baseBranchName: 'main', + upstreamBranchName: 'origin/feature', + incomingChanges: 1, + outgoingChanges: 2, + uncommittedChanges: 3, + hasBaseBranchChanges: true, + githubOwner: 'microsoft', + githubHeadOwner: 'contributor', + githubRepo: 'vscode', + }, + sourceControl: { merge: { commit: 'abc123' }, latestOutcome: 'pullRequest' }, + artifacts: [{ id: 'artifact', type: 'pullRequest', label: 'PR', link: 'https://github.com/microsoft/vscode/pull/1', isGitHub: true, createdByThisSession: true }], + orchestration: { + parentSession: 'agent-session://copilot/parent', + creatorSession: 'agent-session://copilot/creator', + label: 'child', + coordinateWithCreator: true, + notifyOnIdle: 'always', + creatorNotificationState: 'waitingForCompletion', + }, + workingDirectories: ['file:///workspace', 'file:///other'], + chats: [ + { uri: `${session.toString()}/chat/default`, order: 0, kind: 'default', title: 'Catalog title', titleSource: 'user' }, + { uri: `${session.toString()}/chat/peer`, order: 1, kind: 'peer', title: 'Peer title', titleSource: 'agent', origin: { kind: 'fork', chat: `${session.toString()}/chat/default`, turnId: 'turn-1' } }, + ], + }; + + function createDatabase(): TestCatalogDatabase { + const database = disposables.add(new TestCatalogDatabase()); + const projection = projectAgentHostCatalog(source, { + session: session.toString(), + sessionGeneration: 'incarnation', + sourceRevision: 2, + }); + if (!projection.ok) { + throw new Error(projection.error.message); + } + database.catalog = { + ...projection.value.catalog, + provider: registered.provider, + startTime: registered.startTime, + external: registered.external, + source: registered.source, + }; + return database; + } + + test('converts a verified projection-v3 catalog into complete list metadata', 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), + orchestration: readSessionOrchestration(result.metadata._meta), + chats: result.source.chats, + }, { + 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: source.changes, + external: true, + workspaceless: true, + ehcliAdoptable: true, + multiRoot: source.multiRoot, + folderPicker: source.folderPicker, + github: source.github, + git: source.git, + sourceControl: source.sourceControl, + artifacts: source.artifacts, + orchestration: source.orchestration, + chats: source.chats.map(chat => ({ ...chat, origin: chat.origin })), + }); + }); + + test('returns explicit ineligibility reasons without fabricating metadata', async () => { + const cases: Array<{ readonly expected: string; readonly mutate: (database: TestCatalogDatabase) => void }> = [ + { expected: 'missingCatalog', mutate: database => database.catalog = undefined }, + { expected: 'chatBacking', mutate: database => database.catalog = { ...database.catalog!, isChatBacking: true } }, + { expected: 'identityMismatch', mutate: database => database.catalog = { ...database.catalog!, session: AgentSession.uri('copilot', 'other').toString() } }, + { expected: 'providerMismatch', mutate: database => database.catalog = { ...database.catalog!, provider: 'claude' } }, + { expected: 'outdated', mutate: database => database.catalog = { ...database.catalog!, projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION - 1 } }, + { expected: 'malformed', mutate: database => database.catalog = { ...database.catalog!, sourceHash: 'not-the-canonical-hash' } }, + { expected: 'readError', 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.reason); + } + assert.deepStrictEqual(actual, cases.map(testCase => testCase.expected)); + }); + + test('rejects a registry provider that does not match the session identity', async () => { + const result = await new AgentHostCatalogListReader(createDatabase()).read({ ...registered, provider: 'claude' }); + assert.deepStrictEqual(result, { eligible: false, reason: 'providerMismatch' }); + }); +}); 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..85dc992af0ed02 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts @@ -0,0 +1,480 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createHash } from 'crypto'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { + AGENT_HOST_CATALOG_ARTIFACT_LIMIT, + AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT, + AGENT_HOST_CATALOG_PROJECTION_VERSION, + IAgentHostCatalogProjection, + IAgentHostCatalogSource, + parseAgentHostCatalogSourcePayload, + parseAgentHostDatabaseCatalog, + projectAgentHostCatalog, +} from '../../node/agentHostCatalogProjection.js'; + +const options = { + session: 'agent-session://test/session', + sessionGeneration: 'incarnation-1', + sourceRevision: 7, +} as const; + +function createSource(): IAgentHostCatalogSource { + return { + modifiedTime: 1720000000000, + title: 'Implement catalog projection', + titleSource: 'user', + isRead: true, + isArchived: false, + project: { + uri: 'file:///workspace', + displayName: 'workspace', + }, + workspaceless: false, + ehcliAdoptable: true, + multiRoot: { + workspaceFile: 'file:///workspace/project.code-workspace', + }, + folderPicker: { + hidden: true, + primary: 'file:///workspace', + }, + changes: { + additions: 12, + deletions: 4, + files: 2, + }, + github: { + owner: 'microsoft', + repo: 'vscode', + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + initialPullRequestUrls: [], + associatedPullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], + issueUrls: ['https://github.com/microsoft/vscode/issues/2'], + pullRequestBranchName: 'catalog-projection', + }, + git: { + hasGitHubRemote: true, + branchName: 'feature/catalog', + baseBranchName: 'main', + upstreamBranchName: 'origin/feature/catalog', + incomingChanges: 2, + outgoingChanges: 3, + uncommittedChanges: 4, + hasBaseBranchChanges: true, + githubOwner: 'microsoft', + githubHeadOwner: 'contributor', + githubRepo: 'vscode', + }, + sourceControl: { + merge: { commit: '0123456789abcdef' }, + latestOutcome: 'merge', + }, + artifacts: [{ + id: 'artifact-1', + type: 'pullRequest', + label: 'Catalog projection', + link: 'https://github.com/microsoft/vscode/pull/1', + isGitHub: true, + createdByThisSession: true, + }], + orchestration: { + parentSession: 'agent-session://test/parent', + creatorSession: 'agent-session://test/parent', + label: 'projection', + coordinateWithCreator: true, + notifyOnIdle: 'once', + creatorNotificationState: 'waitingForCompletion', + }, + workingDirectories: ['file:///workspace', 'file:///workspace/secondary'], + chats: [{ + uri: 'agent-chat://test/session/default', + order: 0, + kind: 'default', + title: 'Main', + titleSource: 'auto', + origin: { kind: 'default', metadata: { b: 2, a: 1 } }, + }, { + uri: 'agent-chat://test/session/peer', + order: 1, + kind: 'peer', + title: 'Peer', + titleSource: 'agent', + origin: { kind: 'subagent' }, + }], + }; +} + +function project(source: IAgentHostCatalogSource = createSource()): IAgentHostCatalogProjection { + const result = projectAgentHostCatalog(source, options); + assert.strictEqual(result.ok, true); + return result.value; +} + +function errorField(result: ReturnType | ReturnType): string | undefined { + return result.ok ? undefined : result.error.field; +} + +suite('AgentHostCatalogProjection', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('is deterministic across property and chat insertion order', () => { + const source = createSource(); + const reordered: IAgentHostCatalogSource = { + chats: [{ + origin: { kind: 'subagent' }, + titleSource: 'agent', + title: 'Peer', + kind: 'peer', + order: 1, + uri: 'agent-chat://test/session/peer', + }, { + origin: { metadata: { a: 1, b: 2 }, kind: 'default' }, + titleSource: 'auto', + title: 'Main', + kind: 'default', + order: 0, + uri: 'agent-chat://test/session/default', + }], + workingDirectories: source.workingDirectories, + orchestration: { + creatorNotificationState: 'waitingForCompletion', + notifyOnIdle: 'once', + coordinateWithCreator: true, + label: 'projection', + creatorSession: 'agent-session://test/parent', + parentSession: 'agent-session://test/parent', + }, + artifacts: source.artifacts, + sourceControl: { latestOutcome: 'merge', merge: { commit: '0123456789abcdef' } }, + git: { + githubRepo: 'vscode', + githubHeadOwner: 'contributor', + githubOwner: 'microsoft', + hasBaseBranchChanges: true, + uncommittedChanges: 4, + outgoingChanges: 3, + incomingChanges: 2, + upstreamBranchName: 'origin/feature/catalog', + baseBranchName: 'main', + branchName: 'feature/catalog', + hasGitHubRemote: true, + }, + github: source.github, + changes: { files: 2, deletions: 4, additions: 12 }, + folderPicker: { primary: 'file:///workspace', hidden: true }, + multiRoot: source.multiRoot, + ehcliAdoptable: true, + workspaceless: false, + project: { displayName: 'workspace', uri: 'file:///workspace' }, + isArchived: false, + isRead: true, + titleSource: 'user', + title: 'Implement catalog projection', + modifiedTime: 1720000000000, + }; + + const first = project(source); + const second = project(reordered); + assert.deepStrictEqual({ + payloadEqual: first.sourcePayload === second.sourcePayload, + hashEqual: first.catalog.sourceHash === second.catalog.sourceHash, + workingDirectoriesJson: second.catalog.workingDirectoriesJson, + chatOrder: (JSON.parse(second.catalog.chatsJson) as Array<{ uri: string }>).map(chat => chat.uri), + }, { + payloadEqual: true, + hashEqual: true, + workingDirectoriesJson: '["file:///workspace","file:///workspace/secondary"]', + chatOrder: [ + 'agent-chat://test/session/default', + 'agent-chat://test/session/peer', + ], + }); + }); + + test('round trips every list-visible Git field and preserves absent versus zero and false', () => { + const projection = project(); + const parsedPayload = parseAgentHostCatalogSourcePayload(projection.sourcePayload); + const parsedCatalog = parseAgentHostDatabaseCatalog(projection.catalog); + const sparse = project({ + ...createSource(), + git: { + hasGitHubRemote: false, + incomingChanges: 0, + }, + }); + assert.strictEqual(parsedPayload.ok, true); + assert.strictEqual(parsedCatalog.ok, true); + + assert.deepStrictEqual({ + projectedGit: projection.source.git, + catalogGit: projection.catalog.gitSummaryJson, + payloadGit: parsedPayload.value.source.git, + parsedCatalogGit: parsedCatalog.value.source.git, + sparseSourceGit: sparse.source.git, + sparseCatalogGit: sparse.catalog.gitSummaryJson, + }, { + projectedGit: createSource().git, + catalogGit: '{"baseBranchName":"main","branchName":"feature/catalog","githubHeadOwner":"contributor","githubOwner":"microsoft","githubRepo":"vscode","hasBaseBranchChanges":true,"hasGitHubRemote":true,"incomingChanges":2,"outgoingChanges":3,"uncommittedChanges":4,"upstreamBranchName":"origin/feature/catalog"}', + payloadGit: createSource().git, + parsedCatalogGit: createSource().git, + sparseSourceGit: { hasGitHubRemote: false, incomingChanges: 0 }, + sparseCatalogGit: '{"hasGitHubRemote":false,"incomingChanges":0}', + }); + }); + + test('changes the canonical hash for every meaningful Git field', () => { + const source = createSource(); + const baselineHash = project(source).catalog.sourceHash; + const git = source.git!; + const hashes = [ + project({ ...source, git: { ...git, hasGitHubRemote: false } }).catalog.sourceHash, + project({ ...source, git: { ...git, branchName: 'feature/other' } }).catalog.sourceHash, + project({ ...source, git: { ...git, baseBranchName: 'develop' } }).catalog.sourceHash, + project({ ...source, git: { ...git, upstreamBranchName: 'origin/feature/other' } }).catalog.sourceHash, + project({ ...source, git: { ...git, incomingChanges: 5 } }).catalog.sourceHash, + project({ ...source, git: { ...git, outgoingChanges: 6 } }).catalog.sourceHash, + project({ ...source, git: { ...git, uncommittedChanges: 7 } }).catalog.sourceHash, + project({ ...source, git: { ...git, hasBaseBranchChanges: false } }).catalog.sourceHash, + project({ ...source, git: { ...git, githubOwner: 'owner' } }).catalog.sourceHash, + project({ ...source, git: { ...git, githubHeadOwner: 'head-owner' } }).catalog.sourceHash, + project({ ...source, git: { ...git, githubRepo: 'repository' } }).catalog.sourceHash, + ]; + + assert.deepStrictEqual(hashes.map(hash => hash !== baselineHash), Array.from({ length: hashes.length }, () => true)); + }); + + test('rejects invalid Git counts, oversized strings, and unknown fields', () => { + const source = createSource(); + const negative = projectAgentHostCatalog({ ...source, git: { incomingChanges: -1 } }, options); + const unsafe = projectAgentHostCatalog({ ...source, git: { outgoingChanges: Number.MAX_SAFE_INTEGER + 1 } }, options); + const oversized = projectAgentHostCatalog({ ...source, git: { branchName: 'b'.repeat(1025) } }, options); + const unknown = projectAgentHostCatalog({ + ...source, + git: { branchName: 'main', rawPath: '/private/repository' }, + } as IAgentHostCatalogSource, options); + + assert.deepStrictEqual([ + errorField(negative), + errorField(unsafe), + errorField(oversized), + errorField(unknown), + ], [ + 'git.incomingChanges', + 'git.outgoingChanges', + 'git.branchName', + 'git.rawPath', + ]); + }); + + test('changes hash for meaningful list-visible fields but excludes hydrate-on-open state', () => { + const source = createSource(); + const baseline = project(source); + const changed = project({ ...source, isArchived: true }); + const payload = JSON.parse(baseline.sourcePayload) as Record; + + assert.deepStrictEqual({ + hashChanged: baseline.catalog.sourceHash !== changed.catalog.sourceHash, + excludedFields: [ + 'turns', 'drafts', 'annotations', 'providerData', 'configuration', + 'resumeData', 'changesets', 'activity', 'status', + ].filter(field => JSON.stringify(payload).includes(field)), + }, { + hashChanged: true, + excludedFields: [], + }); + }); + + test('canonically hashes, validates, and round trips the adoptable marker', () => { + const adoptable = project(); + const adopted = project({ ...createSource(), ehcliAdoptable: false }); + const missing = JSON.stringify({ + projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + source: { + ...(JSON.parse(adoptable.sourcePayload) as { source: Record }).source, + ehcliAdoptable: undefined, + }, + }); + const invalid = JSON.stringify({ + projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + source: { + ...(JSON.parse(adoptable.sourcePayload) as { source: Record }).source, + ehcliAdoptable: 'true', + }, + }); + const roundTrip = parseAgentHostDatabaseCatalog(adoptable.catalog); + const missingPayload = parseAgentHostCatalogSourcePayload(missing); + const invalidPayload = parseAgentHostCatalogSourcePayload(invalid); + + assert.deepStrictEqual({ + hashChanged: adoptable.catalog.sourceHash !== adopted.catalog.sourceHash, + catalogMarker: adoptable.catalog.ehcliAdoptable, + payloadMarker: (JSON.parse(adoptable.sourcePayload) as { source: { ehcliAdoptable: boolean } }).source.ehcliAdoptable, + roundTripMarker: roundTrip.ok ? roundTrip.value.source.ehcliAdoptable : undefined, + missingPayloadError: missingPayload.ok ? undefined : missingPayload.error.field, + invalidSourceError: invalidPayload.ok ? undefined : invalidPayload.error.field, + }, { + hashChanged: true, + catalogMarker: true, + payloadMarker: true, + roundTripMarker: true, + missingPayloadError: 'sourcePayload', + invalidSourceError: 'ehcliAdoptable', + }); + }); + + test('bounds and de-duplicates each GitHub reference history', () => { + const source = createSource(); + const references = Array.from({ length: AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT + 5 }, (_, index) => `https://github.com/microsoft/vscode/issues/${index}`); + const projection = project({ + ...source, + github: { + ...source.github, + issueUrls: [references[0].toUpperCase(), ...references], + }, + }); + + assert.deepStrictEqual(projection.source.github?.issueUrls, [ + references[0].toUpperCase(), + ...references.slice(1, AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT), + ]); + }); + + test('retains the most recent artifact suffix and round trips it', () => { + const source = createSource(); + const artifacts = Array.from({ length: AGENT_HOST_CATALOG_ARTIFACT_LIMIT + 5 }, (_, index) => ({ + id: `artifact-${index}`, + type: 'resource' as const, + label: `Artifact ${index}`, + uri: `file:///artifact-${index}`, + })); + const projection = project({ ...source, artifacts }); + const parsed = parseAgentHostDatabaseCatalog(projection.catalog); + assert.strictEqual(parsed.ok, true); + + const expectedIds = artifacts.slice(-AGENT_HOST_CATALOG_ARTIFACT_LIMIT).map(artifact => artifact.id); + assert.deepStrictEqual({ + projectedIds: projection.source.artifacts?.map(artifact => artifact.id), + parsedIds: parsed.value.source.artifacts?.map(artifact => artifact.id), + }, { + projectedIds: expectedIds, + parsedIds: expectedIds, + }); + }); + + test('sorts chats and rejects duplicate or non-contiguous exact-set children', () => { + const source = createSource(); + const duplicateDirectory = projectAgentHostCatalog({ + ...source, + workingDirectories: ['file:///workspace', 'file:///workspace'], + }, options); + const duplicateChatUri = projectAgentHostCatalog({ + ...source, + chats: [source.chats[0], { ...source.chats[1], uri: source.chats[0].uri }], + }, options); + const duplicateChatOrder = projectAgentHostCatalog({ + ...source, + chats: [source.chats[0], { ...source.chats[1], order: 0 }], + }, options); + const sparseChatOrder = projectAgentHostCatalog({ + ...source, + chats: [{ ...source.chats[0], order: 1 }, { ...source.chats[1], order: 2 }], + }, options); + + assert.deepStrictEqual([ + errorField(duplicateDirectory), + errorField(duplicateChatUri), + errorField(duplicateChatOrder), + errorField(sparseChatOrder), + ], [ + 'workingDirectories[1]', + 'chats[1].uri', + 'chats[1].order', + 'chats[0].order', + ]); + }); + + test('returns typed failures for malformed or noncanonical structured catalog data', () => { + const catalog = project().catalog; + const malformed = parseAgentHostDatabaseCatalog({ ...catalog, githubSummaryJson: '{' }); + const noncanonical = parseAgentHostDatabaseCatalog({ ...catalog, changesSummaryJson: '{"files":2,"additions":12,"deletions":4}' }); + const tamperedOrigin = parseAgentHostDatabaseCatalog({ + ...catalog, + chatsJson: catalog.chatsJson.replace('{\\"kind\\":\\"default\\",\\"metadata\\":{\\"a\\":1,\\"b\\":2}}', '{\\"kind\\":\\"tampered\\"}'), + }); + + assert.deepStrictEqual([ + errorField(malformed), + errorField(noncanonical), + errorField(tamperedOrigin), + ], [ + 'githubSummaryJson', + 'changesSummaryJson', + 'sourceHash', + ]); + }); + + test('includes projection version in the hashed canonical payload', () => { + const projection = project(); + const payload = JSON.parse(projection.sourcePayload) as { projectionVersion: number; source: unknown }; + const nextVersionPayload = JSON.stringify({ + projectionVersion: payload.projectionVersion + 1, + source: payload.source, + }); + const nextVersionHash = createHash('sha256').update(nextVersionPayload, 'utf8').digest('hex'); + + assert.deepStrictEqual({ + projectionVersion: payload.projectionVersion, + hashMatchesPayload: projection.catalog.sourceHash === createHash('sha256').update(projection.sourcePayload, 'utf8').digest('hex'), + versionChangesHash: projection.catalog.sourceHash !== nextVersionHash, + }, { + projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + hashMatchesPayload: true, + versionChangesHash: true, + }); + }); + + test('identifies projection-v2 source payloads and catalogs as outdated', () => { + const projection = project(); + const parsedPayload = JSON.parse(projection.sourcePayload) as { projectionVersion: number; source: unknown }; + const oldPayload = JSON.stringify({ projectionVersion: 2, source: parsedPayload.source }); + const payloadResult = parseAgentHostCatalogSourcePayload(oldPayload); + const catalogResult = parseAgentHostDatabaseCatalog({ + ...projection.catalog, + projectionVersion: 2, + ehcliAdoptable: undefined, + }); + + assert.deepStrictEqual({ + currentVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + payloadError: payloadResult.ok ? undefined : payloadResult.error, + catalogError: catalogResult.ok ? undefined : catalogResult.error, + }, { + currentVersion: 4, + payloadError: { field: 'sourcePayload.projectionVersion', message: 'Expected projection version 4.' }, + catalogError: { field: 'projectionVersion', message: 'Expected projection version 4.' }, + }); + }); + + test('round trips source payload and central catalog type', () => { + const projection = project(); + const parsedPayload = parseAgentHostCatalogSourcePayload(projection.sourcePayload); + const parsedCatalog = parseAgentHostDatabaseCatalog(projection.catalog); + assert.strictEqual(parsedPayload.ok, true); + assert.strictEqual(parsedCatalog.ok, true); + + assert.deepStrictEqual({ + payloadSource: parsedPayload.value.source, + catalogSource: parsedCatalog.value.source, + catalog: parsedCatalog.value.catalog, + }, { + payloadSource: projection.source, + catalogSource: projection.source, + catalog: projection.catalog, + }); + }); +}); 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..1d27f138945358 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts @@ -0,0 +1,273 @@ +/*--------------------------------------------------------------------------------------------- + * 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 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 } from '../../node/agentHostCatalogReconciliationService.js'; +import { AgentHostCatalogSyncService } from '../../node/agentHostCatalogSyncService.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabaseSessionV2Projection } 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 catalogSource(title: string) { + return { + modifiedTime: 1, + title, + titleSource: 'user' as const, + isRead: false, + isArchived: false, + workspaceless: true, + workingDirectories: [], + chats: [{ + uri: `agenthost-chat:${title}/default`, + order: 0, + kind: 'default' as const, + title, + titleSource: 'user' as const, + }], + }; +} + +function registered(name: string): IRegisteredSession { + return { + session: URI.parse(`agenthost:${name}`), + provider: 'copilot', + startTime: 1, + external: false, + source: 'explicit', + }; +} + +class TestStorageService implements IAgentHostStorageService { + declare readonly _serviceBrand: undefined; + readonly onDidChange = Event.None; + 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); + } + + delete(key: string): void { + this._values.delete(key); + } + + async whenIdle(): Promise { } +} + +class RecordingCatalogDatabase extends AgentHostDatabase { + upsertCalls = 0; + failUpsert = false; + + constructor() { + super(':memory:'); + } + + override async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { + this.upsertCalls++; + if (this.failUpsert) { + throw new Error('central unavailable'); + } + return super.upsertSessionV2(projection, expectedSessionGeneration); + } +} + +interface ITestHarness { + readonly central: RecordingCatalogDatabase; + readonly locals: Map; + readonly sync: AgentHostCatalogSyncService; + createService(resolveSource?: (session: IRegisteredSession) => Promise): 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.registerSession(session.session.toString(), { + provider: session.provider, + startTime: session.startTime, + source: session.source, + }, { checkTombstone: false }); + } + const locals = new Map(); + 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 => { + 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, + createService: (resolveSource = async session => ({ + status: 'available', + request: { source: catalogSource(session.session.path), legacyMetadata: { customTitle: session.session.path } }, + })) => store.add(new AgentHostCatalogReconciliationService( + sessionDataService, + central, + sync, + storage, + async () => sessions, + resolveSource, + new NullLogService(), + )), + }; + } + + test('skips only an exact sessions_v2 row, compact receipt, and canonical legacy match', async () => { + const harness = await createHarness(['one']); + const session = registered('one'); + await harness.sync.synchronize(session.session, { source: catalogSource('one'), legacyMetadata: { customTitle: 'one' } }); + harness.central.upsertCalls = 0; + const service = harness.createService(); + + const first = await service.runPass(); + const second = await service.runPass(); + + assert.deepStrictEqual({ + first: first.outcomes, + second: second.outcomes, + upsertCalls: harness.central.upsertCalls, + }, { + first: [{ session: 'agenthost:one', status: 'skipped', reason: 'synchronized' }], + second: [{ session: 'agenthost:one', status: 'skipped', reason: 'synchronized' }], + upsertCalls: 0, + }); + }); + + 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, { source: catalogSource('one'), legacyMetadata: { customTitle: 'one' } }); + const pending = await requiredLocal(harness.locals, session.session).getCatalogSyncSnapshot(); + harness.central.failUpsert = false; + + const report = await harness.createService().runPass(); + const acknowledged = await requiredLocal(harness.locals, session.session).getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + before: { state: pending?.state, hasPayload: pending?.payload !== undefined }, + outcomes: report.outcomes, + after: { state: acknowledged?.state, payload: acknowledged?.payload }, + catalogTitle: (await harness.central.getSessionV2(session.session.toString()))?.title, + }, { + before: { state: 'pending', hasPayload: true }, + outcomes: [{ session: 'agenthost:one', status: 'succeeded', reason: 'pendingReplayed', sourceRevision: 0 }], + after: { state: 'acknowledged', payload: undefined }, + catalogTitle: 'one', + }); + }); + + 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, { source: catalogSource('one'), legacyMetadata: { customTitle: 'one' } }); + await requiredLocal(harness.locals, session.session).setMetadata('customTitle', 'old-title'); + + const report = await harness.createService(async () => ({ + status: 'available', + request: { source: catalogSource('old-title'), legacyMetadata: { customTitle: 'old-title' } }, + })).runPass(); + const receipt = await requiredLocal(harness.locals, session.session).getCatalogSyncSnapshot(); + + assert.deepStrictEqual({ + outcomes: report.outcomes, + title: (await harness.central.getSessionV2(session.session.toString()))?.title, + revision: receipt?.sourceRevision, + payload: receipt?.payload, + }, { + outcomes: [{ session: 'agenthost:one', status: 'succeeded', reason: 'synchronized', sourceRevision: 1 }], + title: 'old-title', + revision: 1, + payload: undefined, + }); + }); + + 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, { source: catalogSource('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: { source: catalogSource('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('reports missing session databases explicitly for retry', async () => { + const missing = new Set(['agenthost:missing']); + const harness = await createHarness(['missing'], missing); + + assert.deepStrictEqual((await harness.createService().runPass()).outcomes, [ + { session: 'agenthost:missing', status: 'retry', reason: 'missingDatabase' }, + ]); + }); + + 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/agentHostCatalogShadowValidator.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogShadowValidator.test.ts new file mode 100644 index 00000000000000..f334d29aad85f1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogShadowValidator.test.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentSession, type IAgentSessionMetadata } from '../../common/agent.js'; +import { SessionArtifactType, withSessionArtifacts } from '../../common/sessionArtifacts.js'; +import { SessionSourceControlOutcome, SessionStatus, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, withSessionSourceControlState, withSessionWorkspaceless, type SessionMeta } from '../../common/state/sessionState.js'; +import { projectAgentHostCatalog, type IAgentHostCatalogSource } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostCatalogShadowValidator, type IAgentHostCatalogShadowValidationReport, type IAgentHostCatalogShadowValidationReporter } from '../../node/agentHostCatalogShadowValidator.js'; +import { AgentHostDatabase, type IAgentHostDatabaseSessionV2 } from '../../node/agentHostDatabase.js'; +import type { IRegisteredSession } from '../../node/agentSessionRegistry.js'; + +class RecordingReporter implements IAgentHostCatalogShadowValidationReporter { + readonly reports: IAgentHostCatalogShadowValidationReport[] = []; + + report(report: IAgentHostCatalogShadowValidationReport): void { + this.reports.push(report); + } +} + +class ShadowCatalogDatabase extends AgentHostDatabase { + readonly catalogs = new Map(); + activeReads = 0; + maxConcurrentActiveReads = 0; + activeReadDelay = 0; + + constructor() { + super(':memory:'); + } + + override async getSessionV2(session: string): Promise { + this.activeReads++; + this.maxConcurrentActiveReads = Math.max(this.maxConcurrentActiveReads, this.activeReads); + try { + if (this.activeReadDelay > 0) { + await timeout(this.activeReadDelay); + } + const value = this.catalogs.get(session); + if (value instanceof Error) { + throw value; + } + return value; + } finally { + this.activeReads--; + } + } +} + +suite('AgentHostCatalogShadowValidator', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function source(overrides: Partial = {}): IAgentHostCatalogSource { + return { + modifiedTime: 2, + title: 'Session', + titleSource: 'user', + isRead: true, + isArchived: true, + project: { uri: 'file:///project', displayName: 'Project' }, + workspaceless: true, + ehcliAdoptable: true, + multiRoot: { workspaceFile: 'file:///workspace.code-workspace' }, + folderPicker: { hidden: true, primary: 'file:///project' }, + changes: { additions: 1, deletions: 2, files: 3 }, + github: { owner: 'owner', repo: 'repo', pullRequestUrls: ['https://example.invalid/pr/1'] }, + git: { + hasGitHubRemote: true, + branchName: 'feature', + baseBranchName: 'main', + upstreamBranchName: 'origin/feature', + incomingChanges: 1, + outgoingChanges: 2, + uncommittedChanges: 3, + hasBaseBranchChanges: true, + githubOwner: 'owner', + githubHeadOwner: 'contributor', + githubRepo: 'repo', + }, + sourceControl: { merge: { commit: 'abc' }, latestOutcome: 'merge' }, + artifacts: [{ id: 'artifact', type: 'file', label: 'Artifact', uri: 'file:///artifact' }], + orchestration: { + parentSession: 'copilot:/parent', + creatorSession: 'copilot:/creator', + coordinateWithCreator: true, + notifyOnIdle: 'once', + }, + workingDirectories: ['file:///project'], + chats: [], + ...overrides, + }; + } + + function metadata(id: string, value = source()): IAgentSessionMetadata { + const session = AgentSession.uri('copilot', id); + let meta: SessionMeta | undefined; + meta = withSessionWorkspaceless(meta, value.workspaceless); + if (value.ehcliAdoptable) { + meta = withSessionEhcliAdoptable(meta); + } + meta = withSessionMultiRootMetadata(meta, value.multiRoot); + meta = withSessionFolderPickerDecision(meta, value.folderPicker); + meta = withSessionGitHubState(meta, value.github); + meta = withSessionGitState(meta, value.git); + meta = withSessionSourceControlState(meta, value.sourceControl ? { + merge: value.sourceControl.merge, + latestOutcome: value.sourceControl.latestOutcome === 'merge' ? SessionSourceControlOutcome.Merge : SessionSourceControlOutcome.PullRequest, + } : undefined); + meta = withSessionArtifacts(meta, value.artifacts?.map(artifact => ({ ...artifact, type: artifact.type as SessionArtifactType })) ?? []); + if (value.orchestration) { + meta = withSessionOrchestration(meta, value.orchestration); + } + return { + session, + startTime: 1, + modifiedTime: value.modifiedTime, + summary: value.title, + status: SessionStatus.Idle | (value.isRead ? SessionStatus.IsRead : 0) | (value.isArchived ? SessionStatus.IsArchived : 0), + project: value.project ? { uri: URI.parse(value.project.uri), displayName: value.project.displayName } : undefined, + workingDirectories: value.workingDirectories.map(directory => URI.parse(directory)), + changes: value.changes, + _meta: meta, + }; + } + + function registered(legacy: IAgentSessionMetadata, overrides: Partial = {}): IRegisteredSession { + return { + session: legacy.session, + provider: 'copilot', + startTime: legacy.startTime, + external: false, + source: 'explicit', + ...overrides, + }; + } + + function catalog(session: string, value = source(), options: { sessionGeneration?: string; provider?: IRegisteredSession['provider']; startTime?: number } = {}): IAgentHostDatabaseSessionV2 { + const projected = projectAgentHostCatalog(value, { + session, + sessionGeneration: options.sessionGeneration ?? 'incarnation', + sourceRevision: 0, + }); + assert.ok(projected.ok); + return { + ...projected.value.catalog, + provider: options.provider ?? 'copilot', + startTime: options.startTime ?? 1, + external: false, + source: 'explicit', + }; + } + + function seed(database: ShadowCatalogDatabase, legacy: IAgentSessionMetadata, value = source(), options: { sessionGeneration?: string; provider?: IRegisteredSession['provider']; startTime?: number } = {}): void { + const session = legacy.session.toString(); + database.catalogs.set(session, catalog(session, value, options)); + } + + function createValidator(database: ShadowCatalogDatabase, reporter: RecordingReporter, repair: () => void, concurrency?: number): AgentHostCatalogShadowValidator { + return new AgentHostCatalogShadowValidator(database, reporter, repair, new NullLogService(), concurrency === undefined ? {} : { concurrency }); + } + + test('reports a normalized match without exposing non-comparable title source or chats', async () => { + const database = disposables.add(new ShadowCatalogDatabase()); + const reporter = new RecordingReporter(); + const legacy = metadata('matched'); + seed(database, legacy); + let repairs = 0; + + await createValidator(database, reporter, () => repairs++).validate([legacy], [registered(legacy)]); + + assert.deepStrictEqual({ + total: reporter.reports[0].total, + matched: reporter.reports[0].counts.matched, + titleSourceNotComparable: reporter.reports[0].counts.titleSourceNotComparable, + chatsNotComparable: reporter.reports[0].counts.chatsNotComparable, + repairs, + }, { + total: 1, + matched: 1, + titleSourceNotComparable: 1, + chatsNotComparable: 1, + repairs: 0, + }); + }); + + test('categorizes missing, malformed, and validator exceptions and schedules one repair', async () => { + const database = disposables.add(new ShadowCatalogDatabase()); + const reporter = new RecordingReporter(); + const missing = metadata('missing'); + const malformed = metadata('malformed'); + const failed = metadata('failed'); + seed(database, malformed); + database.catalogs.set(malformed.session.toString(), { ...database.catalogs.get(malformed.session.toString()) as IAgentHostDatabaseSessionV2, title: 'not canonical' }); + database.catalogs.set(failed.session.toString(), new Error('sensitive: file:///private/path')); + let repairs = 0; + + await createValidator(database, reporter, () => repairs++).validate( + [missing, malformed, failed], + [missing, malformed, failed].map(entry => registered(entry)), + ); + + const report = reporter.reports[0]; + assert.deepStrictEqual({ + missing: report.counts.missing, + malformed: report.counts.malformed, + validationError: report.counts.validationError, + repairs, + containsSensitiveData: JSON.stringify(report).includes('private'), + }, { + missing: 1, + malformed: 1, + validationError: 1, + repairs: 1, + containsSensitiveData: false, + }); + }); + + test('reports every comparable field mismatch category', async () => { + const database = disposables.add(new ShadowCatalogDatabase()); + const reporter = new RecordingReporter(); + const cases: Array<{ category: keyof IAgentHostCatalogShadowValidationReport['counts']; mutate: (value: IAgentHostCatalogSource) => IAgentHostCatalogSource }> = [ + { category: 'modifiedTimeMismatch', mutate: value => ({ ...value, modifiedTime: 3 }) }, + { category: 'titleMismatch', mutate: value => ({ ...value, title: 'Different' }) }, + { category: 'readMismatch', mutate: value => ({ ...value, isRead: false }) }, + { category: 'archiveMismatch', mutate: value => ({ ...value, isArchived: false }) }, + { category: 'projectMismatch', mutate: value => ({ ...value, project: { uri: 'file:///other', displayName: 'Other' } }) }, + { category: 'workspacelessMismatch', mutate: value => ({ ...value, workspaceless: false }) }, + { category: 'adoptableMismatch', mutate: value => ({ ...value, ehcliAdoptable: false }) }, + { category: 'multiRootMismatch', mutate: value => ({ ...value, multiRoot: { workspaceFile: 'file:///other.code-workspace' } }) }, + { category: 'folderPickerMismatch', mutate: value => ({ ...value, folderPicker: { hidden: true } }) }, + { category: 'changesMismatch', mutate: value => ({ ...value, changes: { additions: 10 } }) }, + { category: 'githubMismatch', mutate: value => ({ ...value, github: { owner: 'other' } }) }, + { category: 'gitMismatch', mutate: value => ({ ...value, git: { ...value.git, branchName: 'other' } }) }, + { category: 'sourceControlMismatch', mutate: value => ({ ...value, sourceControl: { latestOutcome: 'pullRequest' } }) }, + { category: 'artifactsMismatch', mutate: value => ({ ...value, artifacts: [] }) }, + { category: 'orchestrationMismatch', mutate: value => ({ ...value, orchestration: { ...value.orchestration!, notifyOnIdle: 'always' } }) }, + { category: 'workingDirectoriesMismatch', mutate: value => ({ ...value, workingDirectories: ['file:///other'] }) }, + ]; + const legacySessions = cases.map((entry, index) => { + const legacy = metadata(`mismatch-${index}`); + seed(database, legacy, entry.mutate(source())); + return legacy; + }); + let repairs = 0; + + await createValidator(database, reporter, () => repairs++).validate(legacySessions, legacySessions.map(entry => registered(entry))); + + const counts = reporter.reports[0].counts; + assert.deepStrictEqual({ + mismatchCounts: cases.map(entry => [entry.category, counts[entry.category]]), + matched: counts.matched, + repairs, + }, { + mismatchCounts: cases.map(entry => [entry.category, 1]), + matched: 0, + repairs: 1, + }); + }); + + test('reports identity, provider, and start-time mismatches with only catalog identity repairable', async () => { + const database = disposables.add(new ShadowCatalogDatabase()); + const reporter = new RecordingReporter(); + const identity = metadata('identity'); + const provider = metadata('provider'); + const start = metadata('start'); + seed(database, identity); + seed(database, provider); + seed(database, start); + database.catalogs.set(identity.session.toString(), { ...database.catalogs.get(identity.session.toString()) as IAgentHostDatabaseSessionV2, session: 'copilot:/other' }); + database.catalogs.set(provider.session.toString(), { ...database.catalogs.get(provider.session.toString()) as IAgentHostDatabaseSessionV2, provider: 'claude' }); + database.catalogs.set(start.session.toString(), { ...database.catalogs.get(start.session.toString()) as IAgentHostDatabaseSessionV2, startTime: 99 }); + let repairs = 0; + + await createValidator(database, reporter, () => repairs++).validate( + [identity, provider, start], + [ + registered(identity), + registered(provider), + registered(start), + ], + ); + + assert.deepStrictEqual({ + identityMismatch: reporter.reports[0].counts.identityMismatch, + providerMismatch: reporter.reports[0].counts.providerMismatch, + startTimeMismatch: reporter.reports[0].counts.startTimeMismatch, + repairs, + }, { + identityMismatch: 1, + providerMismatch: 1, + startTimeMismatch: 1, + repairs: 1, + }); + + test('validates central-only rows against durable top-level eligibility', async () => { + const database = disposables.add(new ShadowCatalogDatabase()); + const reporter = new RecordingReporter(); + const backing = metadata('backing'); + const unexpectedTopLevel = metadata('unexpected-top-level'); + seed(database, backing, source({ isChatBacking: true })); + seed(database, unexpectedTopLevel); + let repairs = 0; + + await createValidator(database, reporter, () => repairs++).validate([], [ + registered(backing), + registered(unexpectedTopLevel), + ]); + + assert.deepStrictEqual({ + total: reporter.reports[0].total, + matched: reporter.reports[0].counts.matched, + topLevelEligibilityMismatch: reporter.reports[0].counts.topLevelEligibilityMismatch, + repairs, + }, { + total: 2, + matched: 1, + topLevelEligibilityMismatch: 1, + repairs: 1, + }); + }); + + test('detects a backing catalog row that legacy lists as top-level', async () => { + const database = disposables.add(new ShadowCatalogDatabase()); + const reporter = new RecordingReporter(); + const legacy = metadata('listed-backing'); + seed(database, legacy, source({ isChatBacking: true })); + let repairs = 0; + + await createValidator(database, reporter, () => repairs++).validate([legacy], [registered(legacy)]); + + assert.deepStrictEqual({ + topLevelEligibilityMismatch: reporter.reports[0].counts.topLevelEligibilityMismatch, + repairs, + }, { + topLevelEligibilityMismatch: 1, + repairs: 1, + }); + }); + }); + + test('bounds central validation concurrency', async () => { + const database = disposables.add(new ShadowCatalogDatabase()); + database.activeReadDelay = 5; + const reporter = new RecordingReporter(); + const sessions = Array.from({ length: 8 }, (_, index) => metadata(`concurrency-${index}`)); + for (const legacy of sessions) { + seed(database, legacy); + } + + await createValidator(database, reporter, () => { }, 2).validate(sessions, sessions.map(entry => registered(entry))); + + assert.deepStrictEqual({ + maxConcurrent: database.maxConcurrentActiveReads, + matched: reporter.reports[0].counts.matched, + }, { + maxConcurrent: 2, + matched: 8, + }); + }); + + test('logs and isolates a rejected background validation', async () => { + const database = disposables.add(new ShadowCatalogDatabase()); + const reporter = new RecordingReporter(); + const warning = new DeferredPromise(); + const logService = new class extends NullLogService { + override info(): void { + throw new Error('validation failed'); + } + + override warn(message: string): void { + warning.complete(message); + } + }; + const validator = new AgentHostCatalogShadowValidator(database, reporter, () => { }, logService); + + validator.schedule([], []); + + assert.strictEqual(await warning.p, '[AgentHostCatalogShadowValidator] Background validation failed'); + }); +}); 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..4eb395333f405b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * 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_PROJECTION_VERSION } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostCatalogSyncService } from '../../node/agentHostCatalogSyncService.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; +import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; + +const session = URI.parse('agenthost:test-session'); + +function source(title: string, chatTitle = title) { + return { + modifiedTime: 1, + title, + titleSource: 'user' as const, + isRead: false, + isArchived: false, + workspaceless: true, + workingDirectories: [], + chats: [{ + uri: 'agenthost-chat:test-session/default', + order: 0, + kind: 'default' as const, + title: chatTitle, + titleSource: 'user' as const, + }], + }; +} + +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 persistedSource = JSON.parse(snapshot.payload).source; + this.calls.push(`local:${snapshot.sourceRevision}:${persistedSource.title}`); + this.writes.push({ metadata: { ...values }, title: persistedSource.title, chatTitle: persistedSource.chats[0].title }); + 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(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { + this.calls.push(`upsert:${projection.sourceRevision}:${projection.title}`); + this.order?.push('upsert'); + if (this.upsertError) { + throw this.upsertError; + } + if (this.seedConcurrentGeneration) { + const generation = this.seedConcurrentGeneration; + this.seedConcurrentGeneration = undefined; + await super.upsertSessionV2({ ...projection, sessionGeneration: generation }, expectedSessionGeneration); + return 'generationMismatch'; + } + return this.upsertResult ?? super.upsertSessionV2(projection, 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.registerSession(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, { source: source('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?.title, + receiptMatchesCatalog: snapshot?.sessionGeneration === catalog?.sessionGeneration + && snapshot?.sourceRevision === catalog?.sourceRevision + && snapshot?.projectionVersion === catalog?.projectionVersion + && snapshot?.payloadHash === catalog?.sourceHash, + }, { + 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_PROJECTION_VERSION, + payload: undefined, + payloadHash: snapshot?.payloadHash, + acknowledgedHash: snapshot?.payloadHash, + state: 'acknowledged', + }, + catalogTitle: 'one', + receiptMatchesCatalog: true, + }); + }); + + 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, { source: source('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, { source: source('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 = { source: source('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 catalogSource = source('one'); + + await service.synchronize(session, { + source: catalogSource, + legacyMetadata: { customTitle: 'one', [META_GIT_STATE]: '{"branch":"first"}' }, + }); + + test('advances changed content beyond a newer local pending revision after central failure', async () => { + const { local, central, service } = await createHarness(); + await service.synchronize(session, { source: source('H0'), legacyMetadata: { customTitle: 'H0' } }); + central.upsertError = new Error('central unavailable'); + const failed = await service.synchronize(session, { source: source('H1'), legacyMetadata: { customTitle: 'H1' } }); + const pending = await local.getCatalogSyncSnapshot(); + central.upsertError = undefined; + + const recovered = await service.synchronize(session, { source: source('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: (await central.getSessionV2(session.toString()))?.title, + }, + 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, { source: source('H0'), legacyMetadata: { customTitle: 'H0' } }); + central.getError = new Error('central read unavailable'); + + const first = await service.synchronize(session, { source: source('H1'), legacyMetadata: { customTitle: 'H1' } }); + const second = await service.synchronize(session, { source: source('H2'), legacyMetadata: { customTitle: 'H2' } }); + const pending = await local.getCatalogSyncSnapshot(); + central.getError = undefined; + const recovered = await service.synchronize(session, { source: source('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: (await central.getSessionV2(session.toString()))?.title, + }, + 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, + }); + }); + const first = await local.getCatalogSyncSnapshot(); + const result = await service.synchronize(session, { + source: catalogSource, + 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('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, { source: source('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, { source: source('one'), legacyMetadata: {} }); + const firstGeneration = (await local.getCatalogSyncSnapshot())?.sessionGeneration; + await central.tombstoneAndUnregisterSession(session.toString()); + await central.clearSessionTombstone(session.toString()); + await central.registerSession(session.toString(), { + provider: 'copilotcli', + startTime: 2, + source: 'explicit', + }, { checkTombstone: false }); + + const result = await service.synchronize(session, { source: source('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, { source: source('one', 'chat-one'), legacyMetadata: { customTitle: 'one' } }); + const second = service.synchronize(session, { source: source('two', 'chat-two'), legacyMetadata: { customTitle: 'two' } }); + const third = service.synchronize(session, { source: source('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', + }); + }); +}); 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..8c247e64b5102f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -0,0 +1,416 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { tmpdir } from 'os'; +import type { Database } from '@vscode/sqlite3'; +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, IAgentHostDatabaseSessionV2Projection } 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 createProjection( + session: string, + sessionGeneration: string, + sourceRevision: number, + overrides: Partial = {}, +): IAgentHostDatabaseSessionV2Projection { + return { + session, + sessionGeneration, + modifiedTime: 100 + sourceRevision, + title: `Title ${sourceRevision}`, + titleSource: 'user', + isRead: true, + isArchived: false, + projectUri: 'file:///project', + projectDisplayName: 'Project', + workspaceless: false, + isChatBacking: false, + ehcliAdoptable: true, + workingDirectoriesJson: '["file:///project","file:///project/packages/app"]', + chatsJson: `[{"kind":"default","order":0,"title":"Default","titleSource":"auto","uri":"${session}#default"},{"kind":"peer","order":1,"originJson":"{\\"type\\":\\"subagent\\"}","title":"Peer","titleSource":"agent","uri":"${session}#peer"}]`, + multiRootJson: '{"workspaceFile":"file:///project.code-workspace"}', + folderPickerJson: '{"hidden":false,"primary":"file:///project"}', + changesSummaryJson: '{"files":2}', + githubSummaryJson: '{"owner":"microsoft","repo":"vscode"}', + gitSummaryJson: '{"branchName":"main"}', + sourceControlSummaryJson: '{"latestOutcome":"merge"}', + artifactsJson: '[{"id":"artifact","label":"Artifact","type":"file"}]', + orchestrationJson: '{"coordinateWithCreator":true,"creatorSession":"session://parent","parentSession":"session://parent"}', + sourceRevision, + projectionVersion: 4, + sourceHash: `hash-${sourceRevision}`, + verified: true, + ...overrides, + }; +} + +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 single-table schema without changing the legacy registry', async () => { + const path = join(temporaryDirectory!, 'agent-host.db'); + database = new AgentHostDatabase(path); + await database.registerSession('session://fresh', { + provider: 'copilot', + startTime: 1, + source: 'explicit', + }, { checkTombstone: false }); + await database.close(); + database = undefined; + + const rawDatabase = await openDatabase(path); + try { + const [version, tables, sessionColumns, sessionV2Columns] = 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)'), + ]); + assert.deepStrictEqual({ + version, + tables: tables.map(row => row.name), + sessionColumns: sessionColumns.map(row => row.name), + sessionV2Columns: sessionV2Columns.map(row => row.name), + }, { + version: [{ user_version: 5 }], + tables: ['metadata', 'sessions', 'sessions_v2'], + sessionColumns: ['session_uri', 'provider', 'start_time', 'external', 'registration_source'], + sessionV2Columns: [ + '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', 'is_chat_backing', + ], + }); + } finally { + await close(rawDatabase); + } + }); + + 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, 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, 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, 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.registerSession(session, { + provider: 'copilot', + startTime: 42, + source: 'restore', + }, { checkTombstone: false }); + const projection = createProjection(session, 'generation-1', 7); + + const result = await database.upsertSessionV2(projection, undefined); + + assert.deepStrictEqual({ + result, + row: await database.getSessionV2(session), + rows: await database.listSessionsV2(), + }, { + result: 'applied', + row: { + ...projection, + provider: 'copilot', + startTime: 42, + external: false, + source: 'restore', + }, + rows: [{ + ...projection, + provider: 'copilot', + startTime: 42, + external: false, + source: 'restore', + }], + }); + }); + + test('guards revisions and generation transitions', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://ordering'; + await database.registerSession(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createProjection(session, 'generation-1', 2), undefined); + + const results = { + stale: await database.upsertSessionV2(createProjection(session, 'generation-1', 1), 'generation-1'), + conflict: await database.upsertSessionV2(createProjection(session, 'generation-1', 2, { sourceHash: 'conflict' }), 'generation-1'), + replayed: await database.upsertSessionV2(createProjection(session, 'generation-1', 2), 'generation-1'), + wrongGeneration: await database.upsertSessionV2(createProjection(session, 'generation-2', 0), 'unknown-generation'), + transitioned: await database.upsertSessionV2(createProjection(session, 'generation-2', 0), 'generation-1'), + delayedOldGeneration: await database.upsertSessionV2(createProjection(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: { + ...createProjection(session, 'generation-2', 0), + provider: 'copilot', + startTime: 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.registerSession(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + } + + const upsertResults = await Promise.all(sessions.map(session => database!.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined))); + const racingSession = sessions[0]; + const [racingUpsert] = await Promise.all([ + database.upsertSessionV2(createProjection(racingSession, 'generation-1', 2), 'generation-1'), + database.unregisterSession(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('mirrors registration provenance changes without a catalog revision', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://provenance'; + await database.registerSession(session, { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); + const discovered = await database.getSessionV2(session); + + await database.registerSession(session, { provider: 'ignored-provider', startTime: 2, source: 'restore' }, { checkTombstone: false }); + const restored = await database.getSessionV2(session); + await database.registerSession(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, external: discovered.external, source: discovered.source, sourceRevision: discovered.sourceRevision }, + restored: restored && { provider: restored.provider, startTime: restored.startTime, external: restored.external, source: restored.source, sourceRevision: restored.sourceRevision }, + explicit: explicit && { provider: explicit.provider, startTime: explicit.startTime, external: explicit.external, source: explicit.source, sourceRevision: explicit.sourceRevision }, + }, { + discovered: { provider: 'copilot', startTime: 1, external: true, source: 'discovery', sourceRevision: 1 }, + restored: { provider: 'copilot', startTime: 1, external: false, source: 'restore', sourceRevision: 1 }, + explicit: { provider: 'claude', startTime: 1, external: false, source: 'explicit', sourceRevision: 1 }, + }); + }); + + test('mirrors legacy external provenance backfill without a catalog revision', async () => { + const path = join(temporaryDirectory!, 'external-backfill.db'); + const session = 'session://external-backfill'; + database = new AgentHostDatabase(path); + await database.registerSession(session, { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); + await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); + await database.close(); + database = undefined; + + const rawDatabase = await openDatabase(path); + await exec(rawDatabase, `UPDATE sessions SET external = NULL WHERE session_uri = '${session}'; + UPDATE sessions_v2 SET external = NULL WHERE session_uri = '${session}'`); + await close(rawDatabase); + + database = new AgentHostDatabase(path); + await database.updateSessionExternal([{ 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 deletion cascades and legacy insertion needs no new columns', async () => { + const path = join(temporaryDirectory!, 'old-build.db'); + database = new AgentHostDatabase(path); + await database.registerSession('session://deleted', { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createProjection('session://deleted', 'generation-1', 1), undefined); + await database.unregisterSession('session://deleted'); + 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')`); + const deletedRows = await all(oldBuildDatabase, `SELECT session_uri FROM sessions_v2 WHERE session_uri = 'session://deleted'`); + await close(oldBuildDatabase); + + database = new AgentHostDatabase(path); + assert.deepStrictEqual({ + deletedRows, + oldBuildSession: await database.getSession('session://old-build'), + oldBuildSessionV2: await database.getSessionV2('session://old-build'), + }, { + deletedRows: [], + oldBuildSession: { session: 'session://old-build', provider: 'copilot', startTime: 2, external: true, source: 'discovery' }, + oldBuildSessionV2: undefined, + }); + }); + + test('does not surface v2 orphans deleted by an old connection with foreign keys disabled', async () => { + const path = join(temporaryDirectory!, 'old-build-orphan.db'); + const session = 'session://old-build-orphan'; + database = new AgentHostDatabase(path); + await database.registerSession(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createProjection(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: undefined, + list: [], + }); + }); + + test('does not surface a verified row while its legacy session is tombstoned', async () => { + database = new AgentHostDatabase(':memory:'); + const session = 'session://tombstoned-read'; + await database.registerSession(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); + await database.markSessionTombstoned(session); + + assert.deepStrictEqual({ + get: await database.getSessionV2(session), + list: await database.listSessionsV2(), + }, { + get: undefined, + list: [], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 84fed362bde43e..f35a93e90c649a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -135,7 +135,12 @@ suite('AgentHostGitStateService', () => { ]); }); - function createHarness(options?: { octoKitService?: IAgentHostOctoKitService; agentService?: IAgentService; enterpriseUri?: string }) { + function createHarness(options?: { + octoKitService?: IAgentHostOctoKitService; + agentService?: IAgentService; + enterpriseUri?: string; + persistSessionMetadata?: (session: string, values: Readonly>) => Promise; + }) { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const db = new TestSessionDatabase(); const sessionDataService = createSessionDataService(db); @@ -175,8 +180,18 @@ suite('AgentHostGitStateService', () => { }, } as unknown as IAgentHostOctoKitService; const agentService = { getAuthToken: () => 'token' } as unknown as IAgentService; + const persistenceCalls: { session: string; values: Readonly>; publishedMeta: Readonly> | undefined }[] = []; + const persistSessionMetadata = options?.persistSessionMetadata ?? (async (session: string, values: Readonly>) => { + persistenceCalls.push({ + session, + values, + publishedMeta: stateManager.getSessionState(session)?._meta, + }); + await db.setMetadataValues(values); + }); const service = disposables.add(new AgentHostGitStateService( + persistSessionMetadata, stateManager, gitService, options?.octoKitService ?? octoKitService, @@ -201,6 +216,7 @@ suite('AgentHostGitStateService', () => { gitHubStateEvents, pullRequestCalls, pullRequestShaCalls, + persistenceCalls, setGitResult: (state: ISessionGitState | undefined) => { gitResult = state; }, setGitError: (error: Error) => { gitError = error; }, setHeadSha: (sha: string | undefined) => { headSha = sha; }, @@ -264,6 +280,11 @@ suite('AgentHostGitStateService', () => { afterPullRequest, gitHubStateEvents: h.gitHubStateEvents, persistedAfterPullRequest: persistedAfterPullRequest ? JSON.parse(persistedAfterPullRequest) : undefined, + persistenceCalls: h.persistenceCalls.map(call => ({ + keys: Object.keys(call.values), + publishedGitHub: readSessionGitHubState(call.publishedMeta), + publishedSourceControl: readSessionSourceControlState(call.publishedMeta), + })), }, { afterMerge: { merge: { commit: 'merge-commit' }, @@ -282,6 +303,26 @@ suite('AgentHostGitStateService', () => { merge: { commit: 'merge-commit' }, latestOutcome: SessionSourceControlOutcome.PullRequest, }, + persistenceCalls: [{ + keys: [META_SOURCE_CONTROL_STATE], + publishedGitHub: undefined, + publishedSourceControl: { + merge: { commit: 'merge-commit' }, + latestOutcome: SessionSourceControlOutcome.Merge, + }, + }, { + keys: [META_GITHUB_STATE, META_SOURCE_CONTROL_STATE], + publishedGitHub: { + owner: 'microsoft', + repo: 'vscode', + pullRequestUrls: ['https://github.com/microsoft/vscode/pull/42'], + pullRequestBranchName: 'feature', + }, + publishedSourceControl: { + merge: { commit: 'merge-commit' }, + latestOutcome: SessionSourceControlOutcome.PullRequest, + }, + }], }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index 2cf7198df52ad3..a8d5b9bdecf4da 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -138,6 +138,7 @@ suite('AgentHostSessionTitleController', () => { session: URI; db: TestSessionDatabase; titleActions: string[]; + persistenceWrites: Readonly>[]; copilotApiService: TestCopilotApiService; octoKitService: TestAgentHostOctoKitService; } { @@ -146,6 +147,7 @@ suite('AgentHostSessionTitleController', () => { const session = URI.parse('agenthost-session://copilot/session-title-test'); stateManager.createSession(createSummary(session, title)); const titleActions: string[] = []; + const persistenceWrites: Readonly>[] = []; disposables.add(stateManager.onDidEmitEnvelope(e => { if (e.action.type === ActionType.SessionTitleChanged) { titleActions.push(e.action.title); @@ -160,18 +162,29 @@ suite('AgentHostSessionTitleController', () => { octoKitService, copilotApiService, isActiveAgentTitleGenerationEnabled: () => activeAgentTitleGeneration, + persistSessionMetadata: (_session, values) => { + persistenceWrites.push(values); + void db.setMetadataValues(values); + }, }, new NullLogService())); - return { controller, stateManager, session, db, titleActions, copilotApiService, octoKitService }; + return { controller, stateManager, session, db, titleActions, persistenceWrites, copilotApiService, octoKitService }; } 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); + const { controller, session, db, titleActions, persistenceWrites } = setup(copilotApiService, '', undefined, undefined, undefined, undefined, undefined, true); controller.seedTitleFromFirstMessage(session.toString(), 'Investigate why restored Agent Host sessions sometimes lose titles'); const instruction = await controller.prepareInstructionForAgent(session.toString(), buildDefaultChatUri(session)); assert.deepStrictEqual(titleActions, ['Investigate why restored Agent Host sessions...']); + assert.deepStrictEqual(persistenceWrites, [ + { customTitleSource: AGENT_HOST_TITLE_SOURCE_AUTO }, + { + customTitle: 'Investigate why restored Agent Host sessions...', + customTitleSource: AGENT_HOST_TITLE_SOURCE_AUTO, + }, + ]); assert.strictEqual(copilotApiService.utilityCalls.length, 0); assert.strictEqual(instruction, 'Reminder: This chat currently has an auto-generated or placeholder title. If the user references an issue or PR, fetch it first. Please give the chat a short, descriptive title based on the user\'s intent by calling `rename_chat` with `automatic: true`, silently and without a preamble. The user must not know that you are renaming. Do not skip it.'); await waitForCondition(async () => await db.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY) === AGENT_HOST_TITLE_SOURCE_AUTO, 'auto provenance should be persisted'); @@ -324,7 +337,7 @@ suite('AgentHostSessionTitleController', () => { test('seedTitleFromFirstMessage applies fallback and persists generated title', async () => { const copilotApiService = new TestCopilotApiService(); copilotApiService.response = '"Generated title."'; - const { controller, session, db, titleActions } = setup(copilotApiService); + const { controller, session, db, titleActions, persistenceWrites } = setup(copilotApiService); controller.seedTitleFromFirstMessage(session.toString(), ' Please explain title generation '); await waitForCondition(async () => await db.getMetadata('customTitle') === 'Generated title', 'generated title should be persisted'); @@ -335,12 +348,17 @@ suite('AgentHostSessionTitleController', () => { maxTokens: copilotApiService.utilityCalls[0]?.request.maxTokens, promptIncludesUserText: copilotApiService.utilityCalls[0]?.request.messages.some(message => message.content.includes('Please explain title generation')), persistedTitle: await db.getMetadata('customTitle'), + generatedPersistence: persistenceWrites.at(-1), }, { titles: ['Please explain title generation', 'Generated title'], token: 'gh-token', maxTokens: 32, promptIncludesUserText: true, persistedTitle: 'Generated title', + generatedPersistence: { + customTitle: 'Generated title', + customTitleSource: AGENT_HOST_TITLE_SOURCE_AUTO, + }, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e5b6a84a3d8a23..821c6299b518b9 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -34,20 +34,23 @@ 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 { META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; +import { ISessionCatalogSyncPendingSnapshot, ISessionDatabase, ISessionDataService, SessionCatalogSyncWriteResult } from '../../common/sessionDataService.js'; +import { META_GITHUB_STATE, META_GIT_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js'; -import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_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, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_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, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionGitState, type ISessionOrchestration, 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 { 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, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; +import type { AgentHostCatalogReconciliationSourceResult } from '../../node/agentHostCatalogReconciliationService.js'; +import type { AgentHostCatalogReadMode, IAgentHostCatalogShadowValidationReport, IAgentHostCatalogShadowValidationReporter } from '../../node/agentHostCatalogShadowValidator.js'; +import { projectAgentHostCatalog, type IAgentHostCatalogSource } 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, 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'; @@ -63,6 +66,7 @@ import { SessionServerToolName } from '../../common/serverToolNames.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; +import { readSessionArtifacts, SessionArtifactType, withSessionArtifacts } from '../../common/sessionArtifacts.js'; /** * Replace individual operations on an agent's chat surface, delegating every @@ -216,6 +220,7 @@ class TestCopilotApiService implements ICopilotApiService { class TransientRegistryWriteDatabase implements IAgentHostDatabase { private readonly _sessions = new Map(); + private readonly _sessionsV2 = new Map(); private _backfilled = false; private readonly _providerBackfilled = new Set(); private readonly _tombstones = new Set(); @@ -258,6 +263,7 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { async unregisterSession(session: string): Promise { this._beforeWrite(); this._sessions.delete(session); + this._sessionsV2.delete(session); this._agentMergeEnabled.delete(session); } @@ -265,6 +271,7 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._beforeWrite(); this._tombstones.add(session); this._sessions.delete(session); + this._sessionsV2.delete(session); this._agentMergeEnabled.delete(session); } @@ -342,6 +349,21 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { return [...this._agentMergeEnabled]; } + async getSessionV2(session: string): Promise { return this._sessionsV2.get(session); } + async listSessionsV2(): Promise { return [...this._sessionsV2.values()]; } + async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { + const session = this._sessions.get(projection.session); + if (!session) { + return 'missingSession'; + } + const current = this._sessionsV2.get(projection.session); + if (current?.sessionGeneration !== expectedSessionGeneration) { + return 'generationMismatch'; + } + this._sessionsV2.set(projection.session, { ...session, ...projection }); + return 'applied'; + } + async close(): Promise { } dispose(): void { } @@ -357,10 +379,12 @@ 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 _sessionsV2 = new Map(); private readonly _providerBackfilled = new Set(); private readonly _tombstones = new Set(); private readonly _agentMergeEnabled = new Set(); private _backfilled = false; + catalogListCalls = 0; async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { if (registerOptions.checkTombstone && this._tombstones.has(session)) { @@ -377,12 +401,14 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { async unregisterSession(session: string): Promise { this._sessions.delete(session); + this._sessionsV2.delete(session); this._agentMergeEnabled.delete(session); } async tombstoneAndUnregisterSession(session: string): Promise { this._tombstones.add(session); this._sessions.delete(session); + this._sessionsV2.delete(session); this._agentMergeEnabled.delete(session); } @@ -440,6 +466,30 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { return [...this._agentMergeEnabled]; } + async getSessionV2(session: string): Promise { + this.catalogListCalls++; + return this._sessionsV2.get(session); + } + async listSessionsV2(): Promise { + this.catalogListCalls++; + return [...this._sessionsV2.values()]; + } + async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { + const session = this._sessions.get(projection.session); + if (!session) { + return 'missingSession'; + } + const current = this._sessionsV2.get(projection.session); + if (current?.sessionGeneration !== expectedSessionGeneration) { + return 'generationMismatch'; + } + if (current?.sessionGeneration === projection.sessionGeneration && current.sourceRevision === projection.sourceRevision) { + return current.projectionVersion === projection.projectionVersion && current.sourceHash === projection.sourceHash ? 'replayed' : 'conflict'; + } + this._sessionsV2.set(projection.session, { ...session, ...projection }); + return 'applied'; + } + async close(): Promise { } dispose(): void { } } @@ -470,6 +520,15 @@ suite('AgentService (node dispatcher)', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); + test('starts catalog reconciliation in the background and exposes an awaitable idle hook', async () => { + service.registerProvider(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', () => { @@ -1114,7 +1173,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(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); reopened.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); @@ -1181,7 +1240,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, @@ -2958,6 +3017,50 @@ suite('AgentService (node dispatcher)', () => { } } + class CentralCatalogDatabase extends TestAgentHostOrchestratorDatabase { + private readonly _catalogs = new Map(); + + setCatalog(session: URI, source: IAgentHostCatalogSource): void { + const projection = projectAgentHostCatalog(source, { + session: session.toString(), + sessionGeneration: 'test-generation', + sourceRevision: 1, + }); + if (!projection.ok) { + throw new Error(projection.error.message); + } + this._catalogs.set(session.toString(), projection.value.catalog); + } + + override async getSessionV2(session: string): Promise { + const catalog = this._catalogs.get(session); + const registered = await this.getSession(session); + return catalog && registered ? { ...registered, ...catalog } : undefined; + } + } + + class CountingMetadataAgent extends TimedExternalAgent { + metadataCalls: string[] = []; + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + this.metadataCalls.push(resolveAgentChatContext(context, chat).configurationResource.toString()); + return super.getChatMetadata(chat, context); + } + } + + function centralSource(modifiedTime: number, title: string, ehcliAdoptable = false): IAgentHostCatalogSource { + return { + modifiedTime, + title, + isRead: false, + isArchived: false, + workspaceless: false, + ehcliAdoptable, + workingDirectories: [], + chats: [], + }; + } + function createExternalSessionService(now: () => number, sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase): AgentService { return disposables.add(new AgentService( new NullLogService(), @@ -2978,6 +3081,35 @@ suite('AgentService (node dispatcher)', () => { )); } + function createCatalogReadModeService( + sessionDataService: ISessionDataService, + orchestratorDatabase: IAgentHostDatabase, + readMode: AgentHostCatalogReadMode, + reporter: IAgentHostCatalogShadowValidationReporter, + now: () => number = Date.now, + ): AgentService { + return disposables.add(new AgentService( + new NullLogService(), + fileService, + sessionDataService, + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + orchestratorDatabase, + now, + undefined, + readMode, + reporter, + )); + } + function setExternalSessionsMode(service: AgentService, mode: AgentHostExternalSessionsMode, clientSeq: number): void { service.dispatchAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, @@ -3021,6 +3153,465 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions.length, 1); }); + test('listSessions does not read the central catalog during dual write', async () => { + const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); + const svc = disposables.add(new AgentService( + new NullLogService(), + fileService, + createSessionDataService(), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + orchestratorDatabase, + )); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + svc.registerProvider(agent); + await svc.createSession({ provider: 'copilot' }); + orchestratorDatabase.catalogListCalls = 0; + + await svc.listSessions(); + + assert.strictEqual(orchestratorDatabase.catalogListCalls, 0); + }); + + 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.registerSession(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + orchestratorDatabase.setCatalog(session, centralSource(20, 'Central')); + const backingSession = AgentSession.uri('copilot', 'central-backing'); + await orchestratorDatabase.registerSession(backingSession.toString(), { + provider: 'copilot', + startTime: 11, + source: 'explicit', + }, { checkTombstone: false }); + orchestratorDatabase.setCatalog(backingSession, { ...centralSource(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 = createCatalogReadModeService(sessionDataService, orchestratorDatabase, 'centralWithFallback', { report: () => { } }); + await svc.whenCatalogReconciliationIdle(); + databaseOpens = 0; + const agent = disposables.add(new CountingMetadataAgent('copilot')); + svc.registerProvider(agent); + await timeout(0); + agent.metadataCalls = []; + databaseOpens = 0; + + const listed = await svc.listSessions(); + + assert.deepStrictEqual({ + sessions: listed.map(metadata => ({ session: metadata.session.toString(), title: metadata.summary })), + providerMetadataCalls: agent.metadataCalls, + sessionDatabaseOpens: databaseOpens, + }, { + sessions: [{ session: session.toString(), title: 'Central' }], + providerMetadataCalls: [], + sessionDatabaseOpens: 0, + }); + }); + + test('central modes gate adoptable catalogs without provider or session database reads', async () => { + const outcomes: object[] = []; + for (const readMode of ['centralWithFallback', 'central'] as const) { + const orchestratorDatabase = new CentralCatalogDatabase(); + const session = AgentSession.uri('copilot', `central-adoptable-${readMode}`); + await orchestratorDatabase.registerSession(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + orchestratorDatabase.setCatalog(session, centralSource(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 = createCatalogReadModeService(sessionDataService, orchestratorDatabase, readMode, { report: () => { } }); + await svc.whenCatalogReconciliationIdle(); + databaseOpens = 0; + const agent = disposables.add(new CountingMetadataAgent('copilot')); + svc.registerProvider(agent); + await timeout(0); + agent.metadataCalls = []; + databaseOpens = 0; + + const whileDisabled = await svc.listSessions(); + svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + (svc as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); + const whileEnabled = await svc.listSessions(); + orchestratorDatabase.setCatalog(session, centralSource(20, 'Adopted')); + svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); + (svc as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); + const afterAdoption = await svc.listSessions(); + + outcomes.push({ + readMode, + whileDisabled: whileDisabled.length, + whileEnabled: whileEnabled.map(metadata => metadata.summary), + afterAdoption: afterAdoption.map(metadata => metadata.summary), + providerMetadataCalls: agent.metadataCalls, + sessionDatabaseOpens: databaseOpens, + }); + } + + assert.deepStrictEqual(outcomes, [ + { + readMode: 'centralWithFallback', + whileDisabled: 0, + whileEnabled: ['Adoptable'], + afterAdoption: ['Adopted'], + providerMetadataCalls: [], + sessionDatabaseOpens: 0, + }, + { + readMode: 'central', + whileDisabled: 0, + whileEnabled: ['Adoptable'], + afterAdoption: ['Adopted'], + providerMetadataCalls: [], + sessionDatabaseOpens: 0, + }, + ]); + }); + + 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.registerSession(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + } + orchestratorDatabase.setCatalog(centralSession, centralSource(30, 'Central')); + const databaseOpens: string[] = []; + const baseSessionDataService = createSessionDataService(); + const sessionDataService: ISessionDataService = { + ...baseSessionDataService, + tryOpenDatabase: async session => { + databaseOpens.push(session.toString()); + return baseSessionDataService.tryOpenDatabase(session); + }, + }; + const svc = createCatalogReadModeService(sessionDataService, orchestratorDatabase, 'centralWithFallback', { report: () => { } }); + await svc.whenCatalogReconciliationIdle(); + const agent = disposables.add(new CountingMetadataAgent('copilot')); + agent.addSession('eligible', 30); + agent.addSession('fallback', 25); + svc.registerProvider(agent); + await timeout(0); + await svc.whenCatalogReconciliationIdle(); + agent.metadataCalls = []; + databaseOpens.length = 0; + + const listed = await svc.listSessions(); + + assert.deepStrictEqual({ + sessions: listed.map(metadata => metadata.session.toString()), + providerMetadataCalls: agent.metadataCalls, + sessionDatabaseOpenSessions: [...new Set(databaseOpens)], + sessionDatabaseOpenCount: databaseOpens.length, + }, { + sessions: [centralSession.toString(), fallbackSession.toString()], + providerMetadataCalls: [fallbackSession.toString()], + sessionDatabaseOpenSessions: [fallbackSession.toString()], + sessionDatabaseOpenCount: 2, + }); + }); + + test('central mode omits ineligible rows and lists eligible rows without a provider or 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.registerSession(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + } + orchestratorDatabase.setCatalog(eligible, centralSource(40, 'Available centrally')); + let databaseOpens = 0; + const sessionDataService: ISessionDataService = { + ...createSessionDataService(), + tryOpenDatabase: async () => { + databaseOpens++; + return undefined; + }, + }; + const svc = createCatalogReadModeService(sessionDataService, orchestratorDatabase, 'central', { report: () => { } }); + await svc.whenCatalogReconciliationIdle(); + databaseOpens = 0; + + const listed = await svc.listSessions(); + + assert.deepStrictEqual({ + sessions: listed.map(metadata => metadata.session.toString()), + sessionDatabaseOpens: databaseOpens, + }, { + sessions: [eligible.toString()], + sessionDatabaseOpens: 0, + }); + }); + + test('central list applies the same live state overlay as legacy listing', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'central', { report: () => { } }); + const agent = disposables.add(new MockAgent('copilot')); + svc.registerProvider(agent); + const session = await svc.createSession({ provider: 'copilot' }); + await svc.whenCatalogReconciliationIdle(); + orchestratorDatabase.setCatalog(session, { + ...centralSource(20, 'Persisted title'), + workingDirectories: ['file:///persisted'], + changes: { files: 1 }, + }); + svc.stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionTitleChanged, + title: 'Live title', + }); + svc.stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionMetaChanged, + _meta: withSessionGitState(undefined, { branchName: 'live-branch' }), + }); + + 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: { additions: undefined, deletions: undefined, files: 1 }, + git: { branchName: 'live-branch' }, + }); + }); + + test('central list preserves registry ordering and recent external-session limits', async () => { + const orchestratorDatabase = new CentralCatalogDatabase(); + const now = 10 * 24 * 60 * 60 * 1000; + const sessions: URI[] = []; + for (let index = 0; index < 12; index++) { + const session = AgentSession.uri('copilot', `external-${index}`); + sessions.push(session); + await orchestratorDatabase.registerSession(session.toString(), { + provider: 'copilot', + startTime: index, + source: 'discovery', + }, { checkTombstone: false }); + orchestratorDatabase.setCatalog(session, centralSource(now - index, `Session ${index}`)); + } + const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'central', { report: () => { } }, () => now); + 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.registerSession(session.toString(), { + provider: 'copilot', + startTime: 10, + source: 'explicit', + }, { checkTombstone: false }); + const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'centralWithFallback', { report: () => { } }); + await svc.whenCatalogReconciliationIdle(); + const agent = disposables.add(new CountingMetadataAgent('copilot')); + agent.addSession('repair-later', 20); + svc.registerProvider(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('shadow list returns the exact legacy result without additional session database opens', async () => { + const sessionDatabase = new TestSessionDatabase(); + const baseSessionDataService = createSessionDataService(sessionDatabase); + let listDatabaseOpens = 0; + const sessionDataService: ISessionDataService = { + ...baseSessionDataService, + tryOpenDatabase: async session => { + listDatabaseOpens++; + return baseSessionDataService.tryOpenDatabase(session); + }, + }; + const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); + const reports: IAgentHostCatalogShadowValidationReport[] = []; + const reportReceived = new DeferredPromise(); + const svc = createCatalogReadModeService(sessionDataService, orchestratorDatabase, 'legacy', { + report: report => { + reports.push(report); + reportReceived.complete(); + }, + }, () => 1); + const agent = disposables.add(new MockAgent('copilot')); + svc.registerProvider(agent); + const session = await svc.createSession({ provider: 'copilot' }); + const projected = await orchestratorDatabase.getSessionV2(session.toString()); + agent.sessionMetadataOverrides = { startTime: projected!.startTime }; + exposeListedSessions(svc, [{ + session, + startTime: projected!.startTime, + modifiedTime: 1, + summary: 'Session', + status: SessionStatus.Idle, + }]); + + const legacyStartOpens = listDatabaseOpens; + const legacy = await svc.listSessions(); + const legacyOpens = listDatabaseOpens - legacyStartOpens; + (svc as unknown as { _catalogReadMode: AgentHostCatalogReadMode })._catalogReadMode = 'shadow'; + const shadowStartOpens = listDatabaseOpens; + const shadow = await svc.listSessions(); + const shadowOpens = listDatabaseOpens - shadowStartOpens; + await reportReceived.p; + + const comparable = (metadata: IAgentSessionMetadata) => ({ + ...metadata, + session: metadata.session.toString(), + startTime: 0, + project: metadata.project ? { ...metadata.project, uri: metadata.project.uri.toString() } : undefined, + workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()), + }); + assert.deepStrictEqual(shadow.map(comparable), legacy.map(comparable)); + assert.deepStrictEqual({ + openCountsEqual: shadowOpens === legacyOpens, + legacyOpenedDatabase: legacyOpens > 0, + reports: reports.map(report => ({ total: report.total, matched: report.counts.matched })), + }, { + openCountsEqual: true, + legacyOpenedDatabase: true, + reports: [{ total: 1, matched: 1 }], + }); + }); + + test('shadow list returns before validation and coalesces repeated lists to one latest pass', 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 reports: IAgentHostCatalogShadowValidationReport[] = []; + const twoReportsReceived = new DeferredPromise(); + const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'legacy', { + report: report => { + reports.push(report); + if (reports.length === 2) { + twoReportsReceived.complete(); + } + }, + }, () => 1); + const agent = disposables.add(new MockAgent('copilot')); + svc.registerProvider(agent); + await svc.createSession({ provider: 'copilot' }); + (svc as unknown as { _catalogReadMode: AgentHostCatalogReadMode })._catalogReadMode = 'shadow'; + orchestratorDatabase.deferActiveCatalogReads = true; + + const firstList = await svc.listSessions(); + await firstReadStarted.p; + const repeatedLists = await Promise.all([svc.listSessions(), svc.listSessions(), svc.listSessions()]); + + assert.deepStrictEqual({ + firstListCount: firstList.length, + repeatedListCounts: repeatedLists.map(list => list.length), + activeCatalogReadsWhileBlocked: orchestratorDatabase.activeCatalogReads, + reportsWhileBlocked: reports.length, + }, { + firstListCount: 1, + repeatedListCounts: [1, 1, 1], + activeCatalogReadsWhileBlocked: 1, + reportsWhileBlocked: 0, + }); + + releaseFirstRead.complete(); + await twoReportsReceived.p; + assert.deepStrictEqual({ + activeCatalogReads: orchestratorDatabase.activeCatalogReads, + reports: reports.length, + }, { + activeCatalogReads: 2, + reports: 2, + }); + }); + + test('shadow reporter failure cannot fail listSessions', async () => { + const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); + const reportAttempted = new DeferredPromise(); + const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'shadow', { + report: () => { + reportAttempted.complete(); + throw new Error('reporter failed'); + }, + }); + const agent = disposables.add(new MockAgent('copilot')); + svc.registerProvider(agent); + await svc.createSession({ provider: 'copilot' }); + + const listed = await svc.listSessions(); + await reportAttempted.p; + + assert.strictEqual(listed.length, 1); + }); + test('listSessions discovers provider-native sessions as external and restore preserves provenance', async () => { const db = new TestSessionDatabase(); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -4213,7 +4804,33 @@ 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(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + let shadowReports = 0; + const shadowReportReceived = new DeferredPromise(); + const svc = disposables.add(new AgentService( + new NullLogService(), + fileService, + createSessionDataService(), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + db, + Date.now, + undefined, + 'shadow', + { + report: () => { + shadowReports++; + shadowReportReceived.complete(); + }, + }, + )); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const agent = disposables.add(new NotYetMigratableAgent('copilot')); const legacy = AgentSession.uri('copilot', 'legacy-migration-not-ready'); @@ -4242,21 +4859,26 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ registryWrites: db.registryWriteAttempts, registered: (await svc.getRegisteredSessions()).map(session => session.toString()), + shadowReports, }, { registryWrites: writesBeforeUnavailable, registered: [existing.toString()], + shadowReports: 0, }); agent.enumerable = true; const listed = await svc.listSessions(); + await shadowReportReceived.p; assert.deepStrictEqual({ retriedBeforeFailure: callsAfterFailure > 1, retriedAfterFailure: agent.migrationCalls > callsAfterFailure, listed: listed.map(session => session.session.toString()).sort(), + shadowReports, }, { retriedBeforeFailure: true, retriedAfterFailure: true, listed: [existing.toString(), legacy.toString()].sort(), + shadowReports: 1, }); }); @@ -4904,21 +5526,56 @@ suite('AgentService (node dispatcher)', () => { worktreeRootResolutions++; return []; }; - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, gitService)); + const catalogDatabase = new TestAgentHostOrchestratorDatabase(); + const reports: IAgentHostCatalogShadowValidationReport[] = []; + const reportReceived = new DeferredPromise(); + const svc = disposables.add(new AgentService( + new NullLogService(), + fileService, + createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, + gitService, + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + Date.now, + undefined, + 'legacy', + { report: report => { reports.push(report); reportReceived.complete(); } }, + )); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); svc.registerProvider(agent); const sessions = await svc.listSessions(); + await svc.whenCatalogReconciliationIdle(); + const projected = await catalogDatabase.getSessionV2(sessionUri.toString()); + (svc as unknown as { _catalogReadMode: AgentHostCatalogReadMode })._catalogReadMode = 'central'; + const centralSessions = await svc.listSessions(); + (svc as unknown as { _catalogReadMode: AgentHostCatalogReadMode })._catalogReadMode = 'shadow'; + await svc.listSessions(); + await reportReceived.p; // Twice, because the deleted repair cached per session: one listing cannot tell "never resolves" from "resolves once". 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 }, + projectedProject: projected && { uri: projected.projectUri, displayName: projected.projectDisplayName }, + shadowProjectMismatches: reports.at(-1)?.counts.projectMismatch, persistedRepositoryRoot: await db.getMetadata(WORKTREE_META_REPOSITORY_ROOT), }, { worktreeRootResolutions: 0, project: { uri: linkedCheckout.toString(), displayName: 'parent' }, + centralProject: { uri: linkedCheckout.toString(), displayName: 'parent' }, + projectedProject: { uri: linkedCheckout.toString(), displayName: 'parent' }, + shadowProjectMismatches: 0, persistedRepositoryRoot: linkedCheckout.toString(), }); }); @@ -6727,9 +7384,302 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(readSessionOrchestration(localService.stateManager.getSessionState(sessionResource.toString())?._meta), orchestration); }); - test('does not consume a child notification when its creator cannot be resolved', async () => { - const sessionData = createPerSessionDataService(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + test('catalog sync prefers live orchestration over the persisted fallback', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const child = await localService.createSession({ provider: 'copilot' }); + const waiting: ISessionOrchestration = { + parentSession: 'copilot:/parent', + creatorSession: 'copilot:/creator', + coordinateWithCreator: true, + notifyOnIdle: 'once', + creatorNotificationState: 'waitingForCompletion', + }; + const notified: ISessionOrchestration = { ...waiting, creatorNotificationState: 'notified' }; + const internals = localService as unknown as { + _sessionCoordination: { + setOrchestration(session: string, value: ISessionOrchestration): Promise; + }; + }; + await internals._sessionCoordination.setOrchestration(child.toString(), waiting); + const firstSnapshot = await db.getCatalogSyncSnapshot(); + + await internals._sessionCoordination.setOrchestration(child.toString(), notified); + + const secondSnapshot = await db.getCatalogSyncSnapshot(); + const central = await (localService as unknown as { _orchestratorDatabase: IAgentHostDatabase })._orchestratorDatabase.getSessionV2(child.toString()); + assert.deepStrictEqual({ + revisionAdvanced: firstSnapshot !== undefined && secondSnapshot?.sourceRevision === firstSnapshot.sourceRevision + 1, + projectedOrchestration: central?.orchestrationJson ? JSON.parse(central.orchestrationJson) : undefined, + receiptPayload: secondSnapshot?.payload, + persistedOrchestration: JSON.parse((await db.getMetadata(AH_META_ORCHESTRATION_DB_KEY)) ?? 'null'), + }, { + revisionAdvanced: true, + projectedOrchestration: notified, + receiptPayload: undefined, + persistedOrchestration: notified, + }); + }); + + test('catalog reconciliation prefers persisted orchestration over live provider state', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const child = await localService.createSession({ provider: 'copilot' }); + const persisted: ISessionOrchestration = { + parentSession: 'copilot:/parent', + creatorSession: 'copilot:/creator', + coordinateWithCreator: true, + notifyOnIdle: 'once', + creatorNotificationState: 'waitingForCompletion', + }; + const live: ISessionOrchestration = { ...persisted, creatorNotificationState: 'notified' }; + await db.setMetadata(AH_META_ORCHESTRATION_DB_KEY, JSON.stringify(persisted)); + localService.stateManager.setSessionMeta( + child.toString(), + withSessionOrchestration(localService.stateManager.getSessionSummary(child.toString())?._meta, live), + ); + const internals = localService as unknown as { + _resolveCatalogReconciliationSource(registered: IRegisteredSession): Promise; + }; + + const result = await internals._resolveCatalogReconciliationSource({ + session: child, + provider: 'copilot', + startTime: 1, + external: false, + source: 'explicit', + }); + + assert.deepStrictEqual(result.status === 'available' ? { + source: result.request.source.orchestration, + legacy: JSON.parse(result.request.legacyMetadata[AH_META_ORCHESTRATION_DB_KEY]), + } : result, { + source: persisted, + legacy: persisted, + }); + }); + + test('catalog live sync and reconciliation project the provider-neutral adoptable marker', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const session = await localService.createSession({ provider: 'copilot' }); + localService.stateManager.setSessionMeta( + session.toString(), + withSessionEhcliAdoptable(localService.stateManager.getSessionSummary(session.toString())?._meta), + ); + const internals = localService as unknown as { + _persistListVisibleSessionState(session: URI, values: Readonly>): Promise; + _resolveCatalogReconciliationSource(registered: IRegisteredSession): Promise; + }; + + await internals._persistListVisibleSessionState(session, {}); + const liveSnapshot = await db.getCatalogSyncSnapshot(); + const central = await (localService as unknown as { _orchestratorDatabase: IAgentHostDatabase })._orchestratorDatabase.getSessionV2(session.toString()); + copilotAgent.sessionMetadataOverrides = { _meta: withSessionEhcliAdoptable(undefined) }; + const reconciliation = await internals._resolveCatalogReconciliationSource({ + session, + provider: 'copilot', + startTime: 1, + external: false, + source: 'restore', + }); + + assert.deepStrictEqual({ + live: central?.ehcliAdoptable, + receiptPayload: liveSnapshot?.payload, + reconciliation: reconciliation.status === 'available' ? reconciliation.request.source.ehcliAdoptable : undefined, + }, { + live: true, + receiptPayload: undefined, + reconciliation: true, + }); + }); + + test('catalog sync projects every live Git field and aligns legacy metadata over persisted state', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const session = await localService.createSession({ provider: 'copilot' }); + const liveGit: ISessionGitState = { + hasGitHubRemote: true, + branchName: 'feature', + baseBranchName: 'main', + upstreamBranchName: 'origin/feature', + incomingChanges: 1, + outgoingChanges: 2, + uncommittedChanges: 3, + hasBaseBranchChanges: true, + githubOwner: 'owner', + githubHeadOwner: 'contributor', + githubRepo: 'repo', + }; + await db.setMetadata(META_GIT_STATE, JSON.stringify({ branchName: 'persisted' })); + localService.stateManager.setSessionMeta( + session.toString(), + withSessionGitState(localService.stateManager.getSessionSummary(session.toString())?._meta, liveGit), + ); + const internals = localService as unknown as { + _persistListVisibleSessionState(session: URI, values: Readonly>): Promise; + }; + + await internals._persistListVisibleSessionState(session, {}); + + const snapshot = await db.getCatalogSyncSnapshot(); + const central = await (localService as unknown as { _orchestratorDatabase: IAgentHostDatabase })._orchestratorDatabase.getSessionV2(session.toString()); + assert.deepStrictEqual({ + projectionVersion: snapshot?.projectionVersion, + projectedGit: central?.gitSummaryJson ? JSON.parse(central.gitSummaryJson) : undefined, + receiptPayload: snapshot?.payload, + legacyGit: JSON.parse((await db.getMetadata(META_GIT_STATE)) ?? 'null'), + }, { + projectionVersion: 4, + projectedGit: liveGit, + receiptPayload: undefined, + legacyGit: liveGit, + }); + }); + + test('catalog reconciliation uses strictly parsed persisted Git only as a fallback', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const session = await localService.createSession({ provider: 'copilot' }); + const persistedGit: ISessionGitState = { + hasGitHubRemote: false, + branchName: 'persisted', + incomingChanges: 0, + outgoingChanges: 4, + uncommittedChanges: 0, + hasBaseBranchChanges: false, + }; + await db.setMetadata(META_GIT_STATE, JSON.stringify(persistedGit)); + const internals = localService as unknown as { + _resolveCatalogReconciliationSource(registered: IRegisteredSession): Promise; + }; + + const result = await internals._resolveCatalogReconciliationSource({ + session, + provider: 'copilot', + startTime: 1, + external: false, + source: 'explicit', + }); + + assert.deepStrictEqual(result.status === 'available' ? { + source: result.request.source.git, + legacy: JSON.parse(result.request.legacyMetadata[META_GIT_STATE]), + } : result, { + source: persistedGit, + legacy: persistedGit, + }); + }); + + test('catalog reconciliation clears malformed persisted Git instead of projecting partial state', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const session = await localService.createSession({ provider: 'copilot' }); + await db.setMetadata(META_GIT_STATE, JSON.stringify({ branchName: 'persisted', incomingChanges: -1 })); + const internals = localService as unknown as { + _resolveCatalogReconciliationSource(registered: IRegisteredSession): Promise; + }; + + const result = await internals._resolveCatalogReconciliationSource({ + session, + provider: 'copilot', + startTime: 1, + external: false, + source: 'explicit', + }); + + assert.deepStrictEqual(result.status === 'available' ? { + source: result.request.source.git, + legacy: result.request.legacyMetadata[META_GIT_STATE], + } : result, { + source: undefined, + legacy: '', + }); + }); + + test('external provider seeding projects live Git into the catalog and legacy metadata', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const git: ISessionGitState = { branchName: 'external', outgoingChanges: 2, githubOwner: 'owner', githubRepo: 'repo' }; + const session = AgentSession.uri('copilot', 'external-git'); + const internals = localService as unknown as { + _initializeExternalSessionReadState(metadata: IAgentSessionMetadata): Promise; + _sessionRegistry: AgentSessionRegistry; + }; + await internals._sessionRegistry.register(session, { + provider: 'copilot', + startTime: 1, + source: 'discovery', + }, { checkTombstone: false }); + + await internals._initializeExternalSessionReadState({ + session, + startTime: 1, + modifiedTime: 2, + summary: 'External', + _meta: withSessionGitState(undefined, git), + }); + + const snapshot = await db.getCatalogSyncSnapshot(); + const central = await (localService as unknown as { _orchestratorDatabase: IAgentHostDatabase })._orchestratorDatabase.getSessionV2(session.toString()); + assert.deepStrictEqual({ + projectedGit: central?.gitSummaryJson ? JSON.parse(central.gitSummaryJson) : undefined, + receiptPayload: snapshot?.payload, + legacyGit: JSON.parse((await db.getMetadata(META_GIT_STATE)) ?? 'null'), + workspacelessSentinel: await db.getMetadata(AH_META_WORKSPACELESS_DB_KEY), + }, { + projectedGit: git, + receiptPayload: undefined, + legacyGit: git, + workspacelessSentinel: undefined, + }); + }); + + test('catalog reconciliation replaces an orphaned projection v2 receipt with a fresh current generation', async () => { + const db = new TestSessionDatabase(); + const catalogDatabase = new TransientRegistryWriteDatabase(); + const session = AgentSession.uri('copilot', 'projection-v2'); + await createAgentSession(copilotAgent, { session }); + await catalogDatabase.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); + const oldSnapshot: ISessionCatalogSyncPendingSnapshot = { + sessionGeneration: 'test-generation', + sourceRevision: 7, + projectionVersion: 2, + payload: '{"projectionVersion":2,"source":{}}', + payloadHash: 'projection-v2-hash', + state: 'pending', + }; + await db.setMetadataValuesAndCatalogSyncSnapshot({}, oldSnapshot); + await db.acknowledgeCatalogSyncSnapshot(oldSnapshot); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, catalogDatabase)); + + localService.registerProvider(copilotAgent); + await localService.whenCatalogReconciliationIdle(); + + const upgraded = await db.getCatalogSyncSnapshot(); + assert.deepStrictEqual({ + projectionVersion: upgraded?.projectionVersion, + sourceRevision: upgraded?.sourceRevision, + generationChanged: upgraded?.sessionGeneration !== oldSnapshot.sessionGeneration, + state: upgraded?.state, + }, { + projectionVersion: 4, + sourceRevision: 0, + generationChanged: true, + state: 'acknowledged', + }); + }); + + test('does not consume a child notification when its creator cannot be resolved', async () => { + const sessionData = createPerSessionDataService(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); localService.registerProvider(copilotAgent); const child = await localService.createSession({ provider: 'copilot' }); const orchestration: ISessionOrchestration = { @@ -7094,7 +8044,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). @@ -7128,9 +8082,28 @@ suite('AgentService (node dispatcher)', () => { localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); await localService.restoreSession(session); + const summary = localService.stateManager.getSessionSummary(sessionStr); assert.deepStrictEqual( - { adoptCalls: agent.adoptCalls, restored: !!localService.stateManager.getSessionState(sessionStr) }, - { adoptCalls: 1, restored: true }, + { + adoptCalls: agent.adoptCalls, + restored: !!localService.stateManager.getSessionState(sessionStr), + title: summary?.title, + isRead: summary !== undefined && (summary.status & SessionStatus.IsRead) !== 0, + persistedTitle: await db.getMetadata(SESSION_CUSTOM_TITLE_KEY), + persistedTitleSource: await db.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + persistedIsRead: await db.getMetadata(AH_META_IS_READ_DB_KEY), + hasCatalogSyncSnapshot: await db.getCatalogSyncSnapshot() !== undefined, + }, + { + adoptCalls: 1, + restored: true, + title: 'Adopted Legacy Title', + isRead: true, + persistedTitle: 'Adopted Legacy Title', + persistedTitleSource: 'user', + persistedIsRead: 'true', + hasCatalogSyncSnapshot: true, + }, ); }); @@ -7201,6 +8174,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(new AgentService( + new NullLogService(), + fileService, + createSessionDataService(sessionDatabase), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); + const agent = disposables.add(new AdoptOnOpenAgent()); + localService.registerProvider(agent); + localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + const session = AgentSession.uri('copilot', 'adopted-surfaced-sync'); + const sessionString = session.toString(); + localService.stateManager.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.stateManager.dispatchServerAction(sessionString, { type: ActionType.SessionTitleChanged, title: 'Renamed' }); + localService.stateManager.dispatchServerAction(sessionString, { type: ActionType.SessionIsReadChanged, isRead: true }); + localService.stateManager.dispatchServerAction(sessionString, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + const state = localService.stateManager.getSessionState(sessionString); + localService.stateManager.setSessionMeta(sessionString, withSessionArtifacts(state?._meta, [{ + id: 'artifact', + type: SessionArtifactType.Website, + label: 'Artifact', + link: 'https://example.com', + }])); + 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(new AgentService( + new NullLogService(), + fileService, + createSessionDataService(sessionDatabase), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + Date.now, + undefined, + 'central', + )); + 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: persisted?.artifactsJson ? JSON.parse(persisted.artifactsJson) : undefined, + }, { + title: 'Renamed', + isRead: true, + isArchived: true, + artifacts: [{ + id: 'artifact', + type: SessionArtifactType.Website, + label: 'Artifact', + link: 'https://example.com', + }], + persistedArtifacts: [{ + id: 'artifact', + type: SessionArtifactType.Website, + label: 'Artifact', + link: 'https://example.com', + }], + }); + }); + + test('catalog suppression defers explicit metadata overrides without dropping them', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(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('turning the migrate setting off un-surfaces adoptable legacy sessions that were never opened', async () => { class AdoptOnOpenAgent extends MockAgent { constructor() { super('copilot'); } @@ -8785,7 +9911,7 @@ suite('AgentService (node dispatcher)', () => { await timeout(1); } agent.chatMessages.set(peerChat.toString(), [completedTurn('peer-turn', 'Remember X', 'Remembered')]); - localService.stateManager.removeChat(session.toString(), peerChat.toString()); + localService.stateManager.deleteSession(session.toString()); const sent = Event.toPromise(agent.onDidSendMessage); localService.dispatchAction(buildDefaultChatUri(session), { @@ -9640,6 +10766,228 @@ suite('AgentService (node dispatcher)', () => { return []; } + 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(new AgentService( + 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')); + localService.registerProvider(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 = localService.stateManager.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: afterCreate ? (JSON.parse(afterCreate.chatsJson) as Array<{ uri: string; order: number; kind: string; title?: string }>).map(chat => ({ uri: chat.uri, order: chat.order, kind: chat.kind, title: chat.title })) : undefined, + afterDelete: afterDelete ? (JSON.parse(afterDelete.chatsJson) as Array<{ uri: string; order: number; kind: string }>).map(chat => ({ uri: chat.uri, order: chat.order, kind: chat.kind })) : undefined, + 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('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(new AgentService( + 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')); + localService.registerProvider(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; + + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const restored = localService.stateManager.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: '' }, + { uri: peer.toString(), title: 'Lazy Central Peer' }, + ], + beforeAccess: { + peerCatalogReads: 0, + legacyEnumerations: 0, + peerMaterializations: 0, + }, + afterAccess: { + peerCatalogReads: 0, + legacyEnumerations: 0, + peerMaterializations: 1, + }, + }); + }); + + 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(new AgentService( + 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')); + localService.registerProvider(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' }, + ])); + + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const firstRestore = localService.stateManager.getSessionState(session.toString())?.chats.map(chat => chat.resource); + const repairedCentral = await catalogDatabase.getSessionV2(session.toString()); + await localService.subscribe(legacyPeer, 'legacy-reader'); + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + assert.deepStrictEqual({ + firstRestore, + repairedCentral: repairedCentral ? (JSON.parse(repairedCentral.chatsJson) as Array<{ uri: string }>).map(chat => chat.uri) : undefined, + secondRestore: localService.stateManager.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()], + }); + }); + test('rolls back a new peer chat when its catalog entry cannot be persisted', async () => { class FailingPeerCatalogDatabase extends TestSessionDatabase { failPeerCatalogWrites = false; @@ -10666,6 +12014,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(new AgentService( + 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')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + await db.setMetadata('peerChats', '{"not":"an array"}'); + localService.stateManager.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; @@ -10741,7 +12132,7 @@ suite('AgentService (node dispatcher)', () => { test('a rejected migration write leaves the catalog absent (not a subset) so migration re-runs', 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--; @@ -10761,13 +12152,30 @@ suite('AgentService (node dispatcher)', () => { } } const db = new FailingCatalogDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); + const localService = disposables.add(new AgentService( + 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')); localService.registerProvider(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). + db.failPeerChatsWrites = 1; localService.stateManager.deleteSession(session.toString()); await assert.rejects(() => localService.restoreSession(session), /simulated catalog write failure/); const catalogAfterFailedWrite = await db.getMetadata('peerChats'); @@ -10800,6 +12208,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; @@ -10810,7 +12226,23 @@ suite('AgentService (node dispatcher)', () => { } const db = new RecordingTitleDatabase(); - const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); + const localService = disposables.add(new AgentService( + new NullLogService(), + fileService, + createSessionDataService(db), + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + [], + undefined, + undefined, + catalogDatabase, + )); localService.configurationService.updateRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: true }); const agent = disposables.add(new ServerToolAgent('copilot')); localService.registerProvider(agent); @@ -10843,6 +12275,12 @@ suite('AgentService (node dispatcher)', () => { }); await db.finalRenamePersisted.p; await timeout(0); + const central = await catalogDatabase.getSessionV2(sessionUri); + const centralChats = central ? (JSON.parse(central.chatsJson) as Array<{ uri: string; title?: string; titleSource?: string }>).map(chat => ({ + uri: chat.uri, + title: chat.title, + titleSource: chat.titleSource, + })) : undefined; assert.deepStrictEqual({ singleChatResult, @@ -10857,6 +12295,7 @@ suite('AgentService (node dispatcher)', () => { persistedDefaultChatSource: await db.getMetadata(`customChatTitleSource:${defaultChat}`), persistedChatTitle: await db.getMetadata(`customChatTitle:${peerChat}`), persistedChatSource: await db.getMetadata(`customChatTitleSource:${peerChat}`), + centralChats, }, { singleChatResult: 'Renamed chat to "Single-chat title".', multiChatDefaultResult: 'Renamed chat to "Complete replacement default chat title".', @@ -10870,6 +12309,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' }, + ], }); }); @@ -10878,15 +12321,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; diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index f63f03303a26bb..e4405cec88aaac 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, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry } from '../../node/agentSessionRegistry.js'; class TestAgentHostDatabase implements IAgentHostDatabase { @@ -138,6 +138,10 @@ class TestAgentHostDatabase implements IAgentHostDatabase { return [...this.agentMergeEnabled]; } + async getSessionV2(): Promise { return undefined; } + async listSessionsV2(): Promise { return []; } + async upsertSessionV2(_projection: IAgentHostDatabaseSessionV2Projection, _expectedSessionGeneration: string | undefined): Promise { return 'missingSession'; } + async close(): Promise { } dispose(): void { } diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index f2cc47addcc4c4..324895ef96d76f 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -50,6 +50,7 @@ import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js'; import { AgentHostStorageService } from '../../node/agentHostStorageService.js'; import { applyMcpServerEnablement } from '../../node/shared/mcpCustomizationController.js'; +import { AGENT_HOST_TITLE_SOURCE_USER, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { MockAgent } from './mockAgent.js'; import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; @@ -124,7 +125,10 @@ let customizationEnablementService = createNoopCustomizationEnablementService(); function createTestSideEffects( disposables: DisposableStore, stateManager: AgentHostStateManager, - options: Omit & { localTurns?: AgentHostLocalTurns }, + options: Omit & { + localTurns?: AgentHostLocalTurns; + persistSessionMetadata?: IAgentSideEffectsOptions['persistSessionMetadata']; + }, _gitService?: IAgentHostGitService, telemetryService: ITelemetryService = NullTelemetryService, changesets: IAgentHostChangesetService = new FakeChangesetService(), @@ -145,6 +149,16 @@ function createTestSideEffects( const resolvedOptions: IAgentSideEffectsOptions = { ...options, localTurns: options.localTurns ?? new AgentHostLocalTurns(options.sessionDataService, logService), + persistSessionMetadata: options.persistSessionMetadata ?? ((session, values) => { + try { + const ref = options.sessionDataService.openDatabase(URI.parse(session)); + ref.object.setMetadataValues(values).catch(error => { + logService.warn('[AgentSideEffects.test] Failed to persist metadata', error); + }).finally(() => ref.dispose()); + } catch (error) { + logService.warn('[AgentSideEffects.test] Failed to open metadata database', error); + } + }), }; return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, customizationEnablementService, resolvedOptions)); } @@ -1465,18 +1479,20 @@ suite('AgentSideEffects', () => { // `/rename` persists the new title, so these tests need a session data // service whose `openDatabase` actually returns a database (the default // null service throws). - function createRenameSideEffects(): AgentSideEffects { + function createRenameSideEffects(persistSessionMetadata?: IAgentSideEffectsOptions['persistSessionMetadata']): AgentSideEffects { return createTestSideEffects(disposables, stateManager, { getAgent: () => agent, agents: agentList, sessionDataService: createSessionDataService(), onTurnComplete: () => { }, + persistSessionMetadata, }); } test('redirects /rename to a title change and completes the turn without calling the agent', async () => { setupSession(); - const renameSideEffects = createRenameSideEffects(); + const persistenceCalls: Array<{ session: string; values: Readonly> }> = []; + const renameSideEffects = createRenameSideEffects((session, values) => persistenceCalls.push({ session, values })); const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', @@ -1488,13 +1504,27 @@ suite('AgentSideEffects', () => { renameSideEffects.handleAction(defaultChatUri, action); await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.sendMessageCalls, []); const state = stateManager.getSessionState(sessionUri.toString()); - assert.strictEqual(state?.title, 'Renamed Session'); - assert.strictEqual(stateManager.getActiveTurnId(sessionUri.toString()), undefined); const part = state?.turns.at(-1)?.responseParts[0]; - assert.strictEqual(part?.kind, ResponsePartKind.Markdown); - assert.strictEqual(part?.kind === ResponsePartKind.Markdown ? part.content : undefined, 'Renamed: Renamed Session'); + assert.deepStrictEqual({ + sendMessageCalls: agent.sendMessageCalls, + title: state?.title, + activeTurnId: stateManager.getActiveTurnId(sessionUri.toString()), + response: part?.kind === ResponsePartKind.Markdown ? part.content : undefined, + persistenceCalls, + }, { + sendMessageCalls: [], + title: 'Renamed Session', + activeTurnId: undefined, + response: 'Renamed: Renamed Session', + persistenceCalls: [{ + session: sessionUri.toString(), + values: { + [SESSION_CUSTOM_TITLE_KEY]: 'Renamed Session', + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: AGENT_HOST_TITLE_SOURCE_USER, + }, + }], + }); }); test('/rename without a title completes the turn and leaves the title unchanged', async () => { @@ -1522,10 +1552,12 @@ suite('AgentSideEffects', () => { type: ActionType.RootConfigChanged, config: { [AgentHostActiveAgentTitleGenerationConfigKey]: true }, }); - const renameSideEffects = createRenameSideEffects(); + const persistenceCalls: Array<{ session: string; values: Readonly> }> = []; + const renameSideEffects = createRenameSideEffects((session, values) => persistenceCalls.push({ session, values })); const peerChat = buildChatUri(sessionUri.toString(), 'peer-rename'); stateManager.addChat(sessionUri.toString(), peerChat, { title: 'Automatic peer title' }); renameSideEffects.markTitleAuto(sessionUri.toString(), peerChat, 'Automatic peer title'); + persistenceCalls.length = 0; const renameAction: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-rename', @@ -1538,6 +1570,13 @@ suite('AgentSideEffects', () => { stateManager.getChatState(peerChat)?.title === 'User Peer Title' && stateManager.getActiveTurnId(peerChat) === undefined ) || undefined); + assert.deepStrictEqual(persistenceCalls, [{ + session: sessionUri.toString(), + values: { + [customChatTitleMetadataKey(peerChat)]: 'User Peer Title', + [customChatTitleSourceMetadataKey(peerChat)]: AGENT_HOST_TITLE_SOURCE_USER, + }, + }]); const followUpAction: ChatAction = { type: ActionType.ChatTurnStarted, diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 92fac546cb4ff6..07dabb3ca1dcc2 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -11073,7 +11073,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { first, second, configValues }, - { first: { adopted: true, eligible: true }, second: { adopted: false, eligible: false, native: true }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, + { first: { adopted: true, eligible: true, listVisible: { isRead: true } }, second: { adopted: false, eligible: false, native: true }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11111,7 +11111,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, usages }, { - adopted: { adopted: true, eligible: true }, + adopted: { adopted: true, eligible: true, listVisible: { 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 } } })], @@ -11145,7 +11145,14 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, customTitle }, - { adopted: { adopted: true, eligible: true }, customTitle: 'My Legacy Session' }, + { + adopted: { + adopted: true, + eligible: true, + listVisible: { title: 'My Legacy Session', titleSource: 'user', isRead: true }, + }, + customTitle: undefined, + }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11174,7 +11181,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, isRead }, - { adopted: { adopted: true, eligible: true }, isRead: 'true' }, + { adopted: { adopted: true, eligible: true, listVisible: { isRead: true } }, isRead: undefined }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11253,7 +11260,7 @@ suite('CopilotAgent', () => { const adopted = await ensureDefaultChatAdopted(agent, session); - assert.deepStrictEqual(adopted, { adopted: true, eligible: true }); + assert.deepStrictEqual(adopted, { adopted: true, eligible: true, listVisible: { 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/sessionCoordination.test.ts b/src/vs/platform/agentHost/test/node/sessionCoordination.test.ts index df649abdd846e0..001742d5f44238 100644 --- a/src/vs/platform/agentHost/test/node/sessionCoordination.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionCoordination.test.ts @@ -4,13 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { SessionStatus, type ISessionOrchestration } from '../../common/state/sessionState.js'; -import { transitionSessionCoordination } from '../../node/sessionCoordination.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AH_META_ORCHESTRATION_DB_KEY, readSessionOrchestration, SessionStatus, type ISessionOrchestration, type SessionSummary } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { SessionCoordinationService, transitionSessionCoordination } from '../../node/sessionCoordination.js'; suite('SessionCoordination', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const base: ISessionOrchestration = { parentSession: 'copilot:/parent', @@ -19,6 +22,67 @@ suite('SessionCoordination', () => { notifyOnIdle: 'once', }; + function createSummary(session: URI): SessionSummary { + return { + resource: session.toString(), + provider: 'copilot', + title: 'Child', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + }; + } + + function createService( + stateManager: AgentHostStateManager, + persistSessionMetadata: (session: string, values: Readonly>) => Promise, + ): SessionCoordinationService { + return disposables.add(new SessionCoordinationService(stateManager, persistSessionMetadata, new NullLogService(), { + getSessionMetadata: async () => undefined, + restoreSession: async () => { }, + handleAction: () => { }, + })); + } + + test('persists one orchestration mutation before publishing state', async () => { + const session = URI.parse('agenthost-session://copilot/child'); + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession(createSummary(session)); + const persistenceCalls: Array<{ session: string; values: Readonly> }> = []; + let orchestrationAtPersistence: ISessionOrchestration | undefined; + const service = createService(stateManager, async (persistedSession, values) => { + orchestrationAtPersistence = readSessionOrchestration(stateManager.getSessionSummary(session.toString())?._meta); + persistenceCalls.push({ session: persistedSession, values }); + }); + + await service.setOrchestration(session.toString(), base); + + assert.deepStrictEqual({ + orchestrationAtPersistence, + persistenceCalls, + orchestrationAfterPersistence: readSessionOrchestration(stateManager.getSessionSummary(session.toString())?._meta), + }, { + orchestrationAtPersistence: undefined, + persistenceCalls: [{ + session: session.toString(), + values: { [AH_META_ORCHESTRATION_DB_KEY]: JSON.stringify(base) }, + }], + orchestrationAfterPersistence: base, + }); + }); + + test('does not publish orchestration when coordinated persistence fails', async () => { + const session = URI.parse('agenthost-session://copilot/child-failure'); + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession(createSummary(session)); + const service = createService(stateManager, async () => { + throw new Error('local transaction failed'); + }); + + await assert.rejects(() => service.setOrchestration(session.toString(), base), /local transaction failed/); + assert.strictEqual(readSessionOrchestration(stateManager.getSessionSummary(session.toString())?._meta), undefined); + }); + test('waits for completion only after work starts', () => { assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.Idle, base), { notify: false }); assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.InProgress, base), { diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 0629fda731fb41..502622984e5e71 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(); @@ -794,6 +801,394 @@ 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 v10 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_usage', 'turns'], + title: 'After upgrade', + snapshot: snapshot(1), + }); + }); + + test('migration v10 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 dfc700df347450..bfc0d2d498498d 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 @@ -153,6 +153,48 @@ To avoid an empty list on window startup — before the agent host has started, - Hydrated entries are reconciled against the authoritative `listSessions()` on the first successful `_refreshSessions()`: stale sessions that no longer exist are pruned. - `_shouldTrackSessionCacheChanges()` is a hook (default `true`) the remote provider overrides to suspend dirty-tracking while its sessions are unpublished (offline), so the on-disk snapshot survives an unreachable host. +### Agent Host session catalog + +The local Agent Host maintains a host-wide SQLite catalog beside its registry. +The catalog stores only bounded list-visible session and chat metadata. Per-session +databases continue to own turns, drafts, annotations, detailed changesets, and +opaque provider backing required when a session or chat is opened. + +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. + +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. + +The central catalog stores one validated `sessions_v2` row per registered +session. An upsert atomically replaces the complete row and is guarded by the +session incarnation and source revision. Concurrent first writers converge on +the winning incarnation through a serialized retry, while tombstones and the +registry join prevent stale work or orphaned rows from resurfacing deleted +sessions. Runtime rollback selects legacy read mode; no retained central +generation is required. + +Each row also persists top-level eligibility. Chat-backing sessions therefore +remain hidden after restart without opening their per-session database. For +worktree sessions, both legacy and central projections derive the displayed +project from the persisted repository root rather than the worktree checkout. + +Session listing supports internal legacy, shadow, central-with-fallback, and +central-only modes. Shadow validation is non-blocking and reports aggregate +categories without session content. Central-with-fallback resolves each +registered session independently: verified current-version catalog rows avoid +provider metadata calls and per-session database opens, while missing, stale, +or malformed rows use the legacy path and schedule reconciliation. The +production default remains conservative until rollout explicitly selects a +central mode. + The **only** per-provider difference is the storage key: local uses the fixed `localAgentHost.cachedSessions` (single machine-wide host); remote uses `remoteAgentHost.cachedSessions.${authority}` (one key per connection). ### External session visibility From 7453d67fdcde27faba527d69a535ddd51b8d1afa Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Mon, 24 Aug 2026 16:56:35 +0200 Subject: [PATCH 02/30] agentHost: migrate sessions directly to v2 Make sessions_v2 an independent current registry, import directly from current, legacy, and provider sources, mirror runtime identities for downgrade compatibility, and reconcile cross-version changes with durable exclusions and versioned markers.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostDatabase.ts | 563 ++++++- .../agentHostSessionsV2MigrationService.ts | 321 ++++ .../platform/agentHost/node/agentService.ts | 263 ++-- .../agentHost/node/agentSessionRegistry.ts | 65 +- ...ntHostCatalogReconciliationService.test.ts | 2 +- .../node/agentHostCatalogSyncService.test.ts | 4 +- .../test/node/agentHostDatabase.test.ts | 508 ++++++- .../agentHost/test/node/agentService.test.ts | 1319 ++++++++++++++++- .../test/node/agentSessionRegistry.test.ts | 179 ++- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 36 +- 10 files changed, 3044 insertions(+), 216 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index ccc374a77aa937..e76c6adcb345e8 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -43,6 +43,15 @@ export interface IAgentHostDatabaseExternalUpdate { readonly external: boolean; } +export type AgentHostSessionsV2ExclusionReason = 'backing' | 'subagent' | 'providerAbsent' | 'staleExternal'; + +export interface IAgentHostDatabaseSessionsV2Exclusion { + readonly provider: AgentProvider; + readonly session: string; + readonly reason: AgentHostSessionsV2ExclusionReason; + readonly fingerprint: string; +} + export type AgentHostCatalogTitleSource = 'user' | 'agent' | 'auto'; export type AgentHostCatalogChatKind = 'default' | 'peer'; @@ -91,8 +100,8 @@ export type AgentHostDatabaseSessionV2UpsertResult = 'applied' | 'replayed' | 's 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; @@ -114,12 +123,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 projection version. */ + isSessionsV2Backfilled(provider: AgentProvider, projectionVersion: number): Promise; + /** Records that a provider completed backfill for a specific v2 projection version. */ + markSessionsV2Backfilled(provider: AgentProvider, projectionVersion: 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 a current v2 identity. */ + excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion): 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 @@ -128,6 +164,22 @@ 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 projection 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. */ + reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise; + /** Returns a current v2 registry identity, including one whose projection is incomplete. */ + getSessionV2Registration(session: string): Promise; + /** Lists current v2 registry identities, including rows whose projections 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; upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise; @@ -206,6 +258,60 @@ const migrations = [ version: 6, sql: 'ALTER TABLE sessions_v2 ADD COLUMN ehcli_adopted INTEGER CHECK (ehcli_adopted IN (0, 1))', }, + { + version: 7, + sql: [ + `CREATE TABLE sessions_v2_v7 ( + session_uri TEXT PRIMARY KEY NOT NULL, + 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)), + is_chat_backing INTEGER NOT NULL DEFAULT 0 CHECK (is_chat_backing IN (0, 1)), + ehcli_adopted INTEGER CHECK (ehcli_adopted IN (0, 1)) + )`, + `INSERT INTO sessions_v2_v7 ( + 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, is_chat_backing, ehcli_adopted + ) + SELECT + 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, is_chat_backing, ehcli_adopted + FROM sessions_v2`, + 'DROP TABLE sessions_v2', + 'ALTER TABLE sessions_v2_v7 RENAME TO sessions_v2', + ].join(';\n'), + }, ] as const; function openDatabase(path: string): Promise { @@ -254,6 +360,21 @@ function providerBackfillKey(provider: AgentProvider): string { return `sessionRegistryBackfilled:${provider}`; } +/** Metadata key for a provider's completed current-projection backfill. */ +function sessionsV2BackfillKey(provider: AgentProvider, projectionVersion: number): string { + return `sessionsV2Backfilled:${provider}:v${projectionVersion}`; +} + +const sessionsV2ExcludedKeyPrefix = 'sessionsV2Excluded:'; + +function sessionsV2ExcludedProviderPrefix(provider: AgentProvider): string { + return `${sessionsV2ExcludedKeyPrefix}${provider}:`; +} + +function sessionsV2ExcludedKey(provider: AgentProvider, session: string): string { + return `${sessionsV2ExcludedProviderPrefix(provider)}${session}`; +} + /** Metadata key for a session's durable "explicitly deleted" tombstone. */ function tombstoneKey(session: string): string { return `sessionTombstone:${session}`; @@ -307,7 +428,6 @@ export class AgentHostDatabase implements IAgentHostDatabase { if (!registerOptions.checkTombstone) { await run(database, 'DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]); } - await this._mirrorSessionV2Registry(database, session); await exec(database, 'COMMIT'); return changes > 0; } catch (error) { @@ -339,6 +459,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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 sessions 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}`); @@ -360,7 +481,6 @@ export class AgentHostDatabase implements IAgentHostDatabase { : `CASE WHEN registration_source = 'explicit' THEN 'explicit' ELSE 'restore' END`; await run(database, `UPDATE sessions SET external = ?, registration_source = ${source} WHERE session_uri = ? AND external IS NULL`, [external ? 1 : 0, session]); - await this._mirrorSessionV2Registry(database, session); } await exec(database, 'COMMIT'); } catch (error) { @@ -425,6 +545,88 @@ export class AgentHostDatabase implements IAgentHostDatabase { ); } + async isSessionsV2Backfilled(provider: AgentProvider, projectionVersion: number): Promise { + this._validateProjectionVersion(projectionVersion); + const row = await get(await this._ensureDatabase(), 'SELECT value FROM metadata WHERE key = ?', [sessionsV2BackfillKey(provider, projectionVersion)]); + return row?.value === 'true'; + } + + markSessionsV2Backfilled(provider: AgentProvider, projectionVersion: number): Promise { + this._validateProjectionVersion(projectionVersion); + return this._run( + `INSERT INTO metadata (key, value) VALUES (?, 'true') + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [sessionsV2BackfillKey(provider, projectionVersion)], + ); + } + + 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): Promise { + return this._transactionSequencer.queue(async () => { + const database = await this._ensureDatabase(); + await exec(database, 'BEGIN IMMEDIATE'); + try { + 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 sessions_v2 WHERE session_uri = ?', [exclusion.session]); + await exec(database, 'COMMIT'); + } catch (error) { + await 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'; @@ -442,6 +644,127 @@ 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, 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, external, registration_source) + SELECT session_uri, provider, start_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, source, registerOptions); + if (changes > 0) { + const row = await get(database, 'SELECT session_uri, provider, start_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, external, registration_source) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session_uri) DO UPDATE SET + provider = excluded.provider, + start_time = excluded.start_time, + external = excluded.external, + registration_source = excluded.registration_source`, [ + row.session_uri, + row.provider, + row.start_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 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 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, external, registration_source) + SELECT session_uri, provider, start_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, + 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( @@ -461,14 +784,166 @@ 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, 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, 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 = ?, + 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.external === undefined ? null : legacy.external ? 1 : 0, + legacy.source, + session, + tombstoneKey(session), + sessionsV2ExcludedKey(legacy.provider, session), + ]); + await exec(database, 'COMMIT'); + } catch (error) { + await this._rollback(database, error, `Failed to reconcile sessions_v2 identity ${session} from legacy`); + } + }); + } + + 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 sessions_v2 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 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, 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, 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, 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.* + `SELECT * FROM sessions_v2 - INNER JOIN sessions ON sessions.session_uri = sessions_v2.session_uri 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 = ? 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._toSessionV2(row) : undefined; @@ -477,14 +952,17 @@ export class AgentHostDatabase implements IAgentHostDatabase { async listSessionsV2(): Promise { const rows = await all( await this._ensureDatabase(), - `SELECT sessions_v2.* + `SELECT * FROM sessions_v2 - INNER JOIN sessions ON sessions.session_uri = sessions_v2.session_uri 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`, [], ); @@ -502,11 +980,16 @@ export class AgentHostDatabase implements IAgentHostDatabase { await exec(database, 'COMMIT'); return 'tombstoned'; } - const registry = await get(database, 'SELECT provider, start_time, external, registration_source FROM sessions WHERE session_uri = ?', [projection.session]); + const registry = await get(database, 'SELECT provider, start_time, external, registration_source FROM sessions_v2 WHERE session_uri = ?', [projection.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, projection.session)]); + if (exclusion) { + await exec(database, 'COMMIT'); + return 'missingSession'; + } const current = await get(database, 'SELECT session_generation, source_revision, projection_version, source_hash, verified FROM sessions_v2 WHERE session_uri = ?', [projection.session]); const currentGeneration = current?.session_generation === null || current?.verified !== 1 ? undefined : current?.session_generation as string; if (currentGeneration !== expectedSessionGeneration) { @@ -604,6 +1087,35 @@ export class AgentHostDatabase implements IAgentHostDatabase { }); } + private _registerSessionV2( + database: Database, + session: string, + provider: AgentProvider, + startTime: number, + source: AgentSessionRegistrationSource, + registerOptions: IAgentHostDatabaseRegisterOptions, + ): Promise { + return runReturningChanges( + database, + `INSERT INTO sessions_v2 (session_uri, provider, start_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, + 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, source, source, registerOptions.checkTombstone ? 1 : 0, tombstoneKey(session)], + ); + } + private _validateSessionV2Projection(projection: IAgentHostDatabaseSessionV2Projection): void { for (const [name, value] of [ ['modifiedTime', projection.modifiedTime], @@ -649,6 +1161,22 @@ export class AgentHostDatabase implements IAgentHostDatabase { } } + private _validateProjectionVersion(projectionVersion: number): void { + if (!Number.isSafeInteger(projectionVersion) || projectionVersion < 0) { + throw new Error('Catalog projectionVersion must be a non-negative safe integer'); + } + } + + 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 _validateCanonicalJson(name: string, value: string): unknown { const parsed = JSON.parse(value); if (stableStringify(parsed) !== value) { @@ -693,13 +1221,14 @@ export class AgentHostDatabase implements IAgentHostDatabase { }; } - private _mirrorSessionV2Registry(database: Database, session: string): Promise { - return run(database, `UPDATE sessions_v2 SET - provider = (SELECT provider FROM sessions WHERE session_uri = ?1), - start_time = (SELECT start_time FROM sessions WHERE session_uri = ?1), - external = (SELECT external FROM sessions WHERE session_uri = ?1), - registration_source = (SELECT registration_source FROM sessions WHERE session_uri = ?1) - WHERE session_uri = ?1 AND EXISTS (SELECT 1 FROM sessions WHERE session_uri = ?1)`, [session]); + private _toSessionRegistration(row: Record): IAgentHostDatabaseSession { + return { + session: row.session_uri as string, + provider: row.provider as AgentProvider, + startTime: row.start_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 { diff --git a/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts new file mode 100644 index 00000000000000..2aa4df412ae6a6 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts @@ -0,0 +1,321 @@ +/*--------------------------------------------------------------------------------------------- + * 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_PROJECTION_VERSION } from './agentHostCatalogProjection.js'; +import { IAgentHostCatalogSyncRequest, AgentHostCatalogSyncService } from './agentHostCatalogSyncService.js'; +import { AgentHostSessionsV2ExclusionReason, IAgentHostDatabase, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2 } 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: IAgentHostDatabaseSessionV2 | 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 request: IAgentHostCatalogSyncRequest; + readonly value: 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 marked: boolean; + readonly imported: readonly IAgentHostSessionsV2ImportedCandidate[]; +} + +type AgentHostSessionsV2MigrationStatus = 'skipped' | 'synchronized' | 'excluded' | 'incomplete' | 'failed'; + +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_PROJECTION_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.listSessionsV2(), + 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 && !this._registrationsEqual(candidate.current, candidate.legacy)) + || !candidate.catalog + || candidate.catalog.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_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, + marked: false, + imported: outcomes.flatMap(outcome => outcome.imported ? [outcome.imported] : []), + }; + if (report.incomplete === 0 && report.failed === 0) { + if (!wasBackfilled) { + await this._database.markSessionsV2Backfilled(provider, AGENT_HOST_CATALOG_PROJECTION_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) { + await this._exclude(provider, candidate, permanentExclusion); + return { status: 'excluded' }; + } + const hasMatchingReceipt = candidate.catalog ? await this._hasMatchingReceipt(candidate.session, candidate.catalog) : false; + let effectiveCandidate = candidate; + if (candidate.current && candidate.legacy && candidate.legacy.external !== undefined && !this._registrationsEqual(candidate.current, candidate.legacy) + && !this._isLaterExplicitCurrentIncarnation(candidate.current, candidate.legacy, hasMatchingReceipt)) { + await this._database.reconcileSessionV2RegistrationFromLegacy(session, candidate.legacy); + effectiveCandidate = { ...candidate, current: candidate.legacy }; + 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') { + await this._exclude(provider, candidate, resolution); + return { status: 'excluded' }; + } + if (resolution.status === 'incomplete') { + if (enumerated && !candidate.provider && !candidate.catalog && (candidate.current || candidate.legacy)) { + await this._exclude(provider, candidate, { reason: 'providerAbsent', fingerprint: 'enumeration-v1' }); + return { status: 'excluded' }; + } + return { status: 'incomplete' }; + } + + const newlyRegistered = !effectiveCandidate.current; + 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 }]); + } + + const result = await this._catalogSyncService.synchronize(candidate.session, resolution.request); + return result.status === 'acknowledged' + ? { + status: 'synchronized', + ...(newlyRegistered ? { imported: { session: candidate.session, external: resolution.external, value: resolution.value } } : {}), + } + : { 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 { + await this._database.excludeSessionV2({ + provider, + session: candidate.session.toString(), + reason: exclusion.reason, + fingerprint: exclusion.fingerprint, + }); + } + + private async _hasMatchingReceipt(session: URI, catalog: IAgentHostDatabaseSessionV2): Promise { + if (!catalog.verified || catalog.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { + return false; + } + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return false; + } + try { + const receipt = await ref.object.getCatalogSyncSnapshot(); + return receipt?.state === 'acknowledged' + && receipt.sessionGeneration === catalog.sessionGeneration + && receipt.sourceRevision === catalog.sourceRevision + && receipt.projectionVersion === catalog.projectionVersion + && receipt.payloadHash === catalog.sourceHash; + } finally { + ref.dispose(); + } + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 093ef141e0c4b4..30df7e12e099cf 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -50,7 +50,7 @@ import { findDeepestContainingWorkingDirectory, isMultiRootSession } from '../co import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.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 { AgentSideEffects, type IAgentSideEffectsOptions } from './agentSideEffects.js'; @@ -60,12 +60,13 @@ import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreati import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, type AgentHostTitleSource, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, 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, readSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts } from '../common/sessionArtifacts.js'; -import { AgentHostCatalogSyncService } from './agentHostCatalogSyncService.js'; -import { projectAgentHostCatalog, type AgentHostCatalogJsonValue, type IAgentHostCatalogSource } from './agentHostCatalogProjection.js'; +import { AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; +import { AGENT_HOST_CATALOG_PROJECTION_VERSION, projectAgentHostCatalog, type AgentHostCatalogJsonValue, type IAgentHostCatalogSource } from './agentHostCatalogProjection.js'; import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult } from './agentHostCatalogReconciliationService.js'; import { IAgentHostStorageService } from './agentHostStorageService.js'; import { AgentHostCatalogShadowValidator, type AgentHostCatalogReadMode, type IAgentHostCatalogShadowValidationReporter } from './agentHostCatalogShadowValidator.js'; import { AgentHostCatalogListReader } from './agentHostCatalogListReader.js'; +import { AgentHostSessionsV2CandidateResolution, AgentHostSessionsV2MigrationService, IAgentHostSessionsV2Candidate } from './agentHostSessionsV2MigrationService.js'; import { buildWorktreeFailureNotification, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; @@ -489,6 +490,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _catalogReconciliationService: AgentHostCatalogReconciliationService; private readonly _catalogShadowValidator: AgentHostCatalogShadowValidator; 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>(); @@ -688,6 +690,12 @@ export class AgentService extends Disposable implements IAgentService { this._serverToolHost = collaborators.serverToolHost; this._catalogReadMode = core.catalogReadMode ?? 'legacy'; this._catalogSyncService = new AgentHostCatalogSyncService(this._sessionDataService, this._orchestratorDatabase, this._logService); + this._sessionsV2MigrationService = new AgentHostSessionsV2MigrationService( + this._orchestratorDatabase, + this._sessionDataService, + this._catalogSyncService, + this._logService, + ); this._catalogListReader = new AgentHostCatalogListReader(this._orchestratorDatabase); core.callbackBinder.bind({ canEvictChangeset: changeset => this._canEvictChangeset(changeset), @@ -1131,7 +1139,7 @@ export class AgentService extends Disposable implements IAgentService { this._providerSubscriptions.add(provider.onDidChangeChatData(e => this._onChatDataChanged(e))); this._providerSubscriptions.add(provider.onDidSpawnChat(e => this._onChatSpawned(e))); this._registerSkillCompletionProvider(); - const initialMigration = this._ensureLegacyChatsMigrated(provider); + const initialMigration = this._ensureSessionsV2Imported(provider); this._initialProviderMigrations.set(provider.id, initialMigration); void initialMigration.catch(err => this._logService.warn(`[AgentService] registry migration: failed for late-registered provider ${provider.id}`, err)); @@ -1491,8 +1499,8 @@ 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 _getSessionMetadata(session: URI, registeredOverride?: IRegisteredSession): Promise { + const registered = registeredOverride ?? await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); if (!registered) { return undefined; } @@ -2075,15 +2083,15 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Awaits legacy migration started at provider registration. Provider-owned - * discovery is independent and surfaces unknown chats additively. + * Awaits the direct v2 import started at provider registration. + * Provider-owned discovery still surfaces later unknown chats additively. */ private async _awaitInitialProviderMigration(): Promise { await Promise.all([...this._providers.values()].map(provider => this._awaitInitialProviderMigrationForProvider(provider))); } /** - * 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 @@ -2108,7 +2116,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); this._initialProviderMigrations.set(provider.id, retry); return retry; } @@ -2135,8 +2143,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( @@ -2207,7 +2215,21 @@ export class AgentService extends Disposable implements IAgentService { 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 registeredKeys = new Set(await this._sessionRegistry.listSessionKeys()); + const [runtimeCompatibleKeys, persistedExclusions] = await Promise.all([ + this._sessionRegistry.listRuntimeCompatibleSessionKeys(), + 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; @@ -2224,11 +2246,34 @@ export class AgentService extends Disposable implements IAgentService { alreadyRegistered++; return false; } - if (isSubagentSession(session.toString()) || await this._isChatBacking(session)) { + 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; } 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; } @@ -2238,23 +2283,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({ - ...sessionMetadata, - _meta: withSessionMultiRootMetadata(sessionMetadata._meta, undefined), - }); + const syncResult = await this._catalogSyncService.synchronize( + session, + await this._buildImportedCatalogSyncRequest(provider, sessionMetadata, effectiveExternal, true), + ); + 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)`); @@ -2265,9 +2314,15 @@ export class AgentService extends Disposable implements IAgentService { return false; } }))); + 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(); @@ -2282,88 +2337,124 @@ 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)) { - return; - } - if (await this._sessionRegistry.isBackfilled()) { - await this._sessionRegistry.markProviderBackfilled(provider.id); - return; + private async _importProviderSessionsV2(provider: IAgent, force = false): Promise { + const report = await this._sessionsV2MigrationService.migrateProvider( + provider.id, + async () => { + const sessions = await this._enumerateLegacyProviderSessions(provider); + 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.sourceHash } + : undefined, + candidate => this._resolveSessionsV2ImportCandidate(provider, candidate), + force, + ); + if (!report) { + if (!await this._sessionRegistry.isSessionsV2Backfilled(provider.id, AGENT_HOST_CATALOG_PROJECTION_VERSION)) { + throw new ProviderCatalogUnavailableError(provider.id); } + return; } - const sessions = await this._enumerateLegacyProviderSessions(provider); - if (sessions === undefined) { - throw new ProviderCatalogUnavailableError(provider.id); + if (report.synchronized + report.excluded + report.incomplete + report.failed + report.skipped > 0) { + this._invalidateSessionList(); + } + if (report.incomplete + report.failed > 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, 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({ - ...metadata, - _meta: withSessionMultiRootMetadata(metadata._meta, undefined), - }); - } - 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); - if (registeredExternal) { + 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.incomplete} incomplete, ${report.failed} failed, marker ${report.marked ? 'set' : 'not set'}`); } - private async _initializeExternalSessionReadState(metadata: IAgentSessionMetadata): Promise { - await this._catalogSyncService.synchronizeWithFactory(metadata.session, () => this._buildCatalogSyncRequest(metadata.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' }; + } + const storedIdentity = candidate.current ?? candidate.legacy; + const external = storedIdentity?.external ?? !facts.hostCreated; + const identity: IAgentHostDatabaseSessionOptions = storedIdentity + ? { + provider: storedIdentity.provider, + startTime: storedIdentity.startTime, + source: storedIdentity.external === undefined ? (external ? 'discovery' : 'restore') : storedIdentity.source, + } + : { + provider: provider.id, + startTime: candidate.provider?.startTime ?? Date.now(), + source: external ? 'discovery' : 'restore', + }; + const metadata = candidate.current + ? await this._getSessionMetadata(session, { session, ...identity, external }) + ?? candidate.provider?.value + : 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) }; + } + return { + status: 'ready', + identity, + external, + request: await this._buildImportedCatalogSyncRequest(provider, canonicalMetadata, external, !candidate.current && !candidate.legacy), + value: canonicalMetadata, + }; + } + + private async _buildImportedCatalogSyncRequest(provider: IAgent, metadata: IAgentSessionMetadata, external: boolean, seedExternalRead: boolean): Promise { + const peers = await this._readOrMigrateLegacyPeerChatCatalog(provider, metadata.session); + return this._buildCatalogSyncRequest(metadata.session, { modifiedTime: metadata.modifiedTime, title: metadata.summary, - status: (metadata.status ?? SessionStatus.Idle) | SessionStatus.IsRead, + status: external && seedExternalRead ? (metadata.status ?? SessionStatus.Idle) | SessionStatus.IsRead : metadata.status ?? SessionStatus.Idle, project: metadata.project ? { uri: metadata.project.uri.toString(), displayName: metadata.project.displayName } : undefined, workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()) ?? [], changes: metadata.changes, - meta: metadata._meta, - chats: [{ - uri: buildDefaultChatUri(metadata.session), - kind: 'default', - title: metadata.summary, - }], - }, { [AH_META_IS_READ_DB_KEY]: 'true' }, true)); + meta: external ? withSessionMultiRootMetadata(metadata._meta, undefined) : metadata._meta, + chats: [ + { + uri: buildDefaultChatUri(metadata.session), + kind: 'default', + title: metadata.summary, + }, + ...peers.map(peer => ({ + uri: peer.uri, + kind: 'peer' as const, + origin: this._toCatalogJsonValue(peer.origin), + })), + ], + }, external && seedExternalRead ? { [AH_META_IS_READ_DB_KEY]: 'true' } : {}, true); } private async _isExternalProviderChat(session: URI): Promise { diff --git a/src/vs/platform/agentHost/node/agentSessionRegistry.ts b/src/vs/platform/agentHost/node/agentSessionRegistry.ts index f2dc872ed5e698..ef278a4965c1fc 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 { @@ -66,12 +66,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()); } /** @@ -87,15 +87,20 @@ 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 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, @@ -125,14 +130,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; } @@ -145,7 +150,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) { @@ -159,7 +164,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(); } /** @@ -189,6 +194,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/test/node/agentHostCatalogReconciliationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts index 1d27f138945358..b3b2491768e57c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts @@ -98,7 +98,7 @@ suite('AgentHostCatalogReconciliationService', () => { const central = store.add(new RecordingCatalogDatabase()); const sessions = names.map(registered); for (const session of sessions) { - await central.registerSession(session.session.toString(), { + await central.registerSessionV2(session.session.toString(), { provider: session.provider, startTime: session.startTime, source: session.source, diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts index 4eb395333f405b..0405c3a5839f56 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts @@ -115,7 +115,7 @@ suite('AgentHostCatalogSyncService', () => { async function createHarness(order?: string[]) { const local = new RecordingSessionDatabase(order); const central = store.add(new RecordingCatalogDatabase(order)); - await central.registerSession(session.toString(), { + await central.registerSessionV2(session.toString(), { provider: 'copilotcli', startTime: 1, source: 'explicit', @@ -334,7 +334,7 @@ suite('AgentHostCatalogSyncService', () => { const firstGeneration = (await local.getCatalogSyncSnapshot())?.sessionGeneration; await central.tombstoneAndUnregisterSession(session.toString()); await central.clearSessionTombstone(session.toString()); - await central.registerSession(session.toString(), { + await central.registerSessionV2(session.toString(), { provider: 'copilotcli', startTime: 2, source: 'explicit', diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 8c247e64b5102f..1f015f2ba24b29 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -53,6 +53,7 @@ function createProjection( workspaceless: false, isChatBacking: false, ehcliAdoptable: true, + ehcliAdopted: false, workingDirectoriesJson: '["file:///project","file:///project/packages/app"]', chatsJson: `[{"kind":"default","order":0,"title":"Default","titleSource":"auto","uri":"${session}#default"},{"kind":"peer","order":1,"originJson":"{\\"type\\":\\"subagent\\"}","title":"Peer","titleSource":"agent","uri":"${session}#peer"}]`, multiRootJson: '{"workspaceFile":"file:///project.code-workspace"}', @@ -71,6 +72,80 @@ function createProjection( }; } +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); + } +} + suite('AgentHostDatabase sessions_v2', () => { let database: IAgentHostDatabase | undefined; @@ -94,29 +169,40 @@ suite('AgentHostDatabase sessions_v2', () => { test('creates the single-table schema without changing the legacy registry', async () => { const path = join(temporaryDirectory!, 'agent-host.db'); database = new AgentHostDatabase(path); - await database.registerSession('session://fresh', { + 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, external: false, source: 'explicit' }, + complete: undefined, + }); await database.close(); database = undefined; const rawDatabase = await openDatabase(path); try { - const [version, tables, sessionColumns, sessionV2Columns] = await Promise.all([ + 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 }], + version: [{ user_version: 7 }], tables: ['metadata', 'sessions', 'sessions_v2'], sessionColumns: ['session_uri', 'provider', 'start_time', 'external', 'registration_source'], sessionV2Columns: [ @@ -125,14 +211,94 @@ suite('AgentHostDatabase sessions_v2', () => { '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', 'is_chat_backing', + 'source_revision', 'projection_version', 'source_hash', 'verified', 'is_chat_backing', 'ehcli_adopted', ], + sessionV2ForeignKeys: [], }); + } finally { await close(rawDatabase); } }); + test('upgrades published v4 through v6 rows through the independent v7 schema', 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: 7 }], + foreignKeys: [], + published: { + session: `session://published-${version}`, + provider: 'copilot', + startTime: version, + external: true, + source: 'discovery', + sessionGeneration: `generation-${version}`, + modifiedTime: 100, + title: 'Published', + titleSource: 'user', + isRead: true, + isArchived: false, + projectUri: 'file:///project', + projectDisplayName: 'Project', + workspaceless: false, + isChatBacking: version >= 5, + ehcliAdoptable: true, + ehcliAdopted: version >= 6 ? true : undefined, + workingDirectoriesJson: '["file:///project"]', + chatsJson: '[]', + multiRootJson: '{}', + folderPickerJson: '{}', + changesSummaryJson: '{}', + githubSummaryJson: '{}', + gitSummaryJson: '{}', + sourceControlSummaryJson: '{}', + artifactsJson: '[]', + orchestrationJson: '{}', + sourceRevision: 7, + projectionVersion: 4, + sourceHash: 'published-hash', + verified: true, + }, + directLegacy: undefined, + directCurrent: { + session: `session://direct-${version}`, + provider: 'claude', + startTime: 200 + version, + external: false, + source: 'explicit', + }, + }))); + }); + test('upgrades published v1 through v3 schemas with incomplete v2 rows', async () => { const results: object[] = []; for (const version of [1, 2, 3]) { @@ -197,7 +363,7 @@ suite('AgentHostDatabase sessions_v2', () => { test('round trips one complete verified row', async () => { database = new AgentHostDatabase(':memory:'); const session = 'session://round-trip'; - await database.registerSession(session, { + await database.registerSessionV2(session, { provider: 'copilot', startTime: 42, source: 'restore', @@ -232,7 +398,7 @@ suite('AgentHostDatabase sessions_v2', () => { test('guards revisions and generation transitions', async () => { database = new AgentHostDatabase(':memory:'); const session = 'session://ordering'; - await database.registerSession(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); await database.upsertSessionV2(createProjection(session, 'generation-1', 2), undefined); const results = { @@ -270,14 +436,14 @@ suite('AgentHostDatabase sessions_v2', () => { database = new AgentHostDatabase(':memory:'); const sessions = Array.from({ length: 20 }, (_, index) => `session://concurrent-${index}`); for (const session of sessions) { - await database.registerSession(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); } const upsertResults = await Promise.all(sessions.map(session => database!.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined))); const racingSession = sessions[0]; const [racingUpsert] = await Promise.all([ database.upsertSessionV2(createProjection(racingSession, 'generation-1', 2), 'generation-1'), - database.unregisterSession(racingSession), + database.unregisterSessionV2(racingSession), ]); assert.deepStrictEqual({ @@ -293,16 +459,16 @@ suite('AgentHostDatabase sessions_v2', () => { }); }); - test('mirrors registration provenance changes without a catalog revision', async () => { + test('updates current registration provenance without a catalog revision', async () => { database = new AgentHostDatabase(':memory:'); const session = 'session://provenance'; - await database.registerSession(session, { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); const discovered = await database.getSessionV2(session); - await database.registerSession(session, { provider: 'ignored-provider', startTime: 2, source: 'restore' }, { checkTombstone: false }); + await database.registerSessionV2(session, { provider: 'ignored-provider', startTime: 2, source: 'restore' }, { checkTombstone: false }); const restored = await database.getSessionV2(session); - await database.registerSession(session, { provider: 'claude', startTime: 3, source: 'explicit' }, { checkTombstone: false }); + await database.registerSessionV2(session, { provider: 'claude', startTime: 3, source: 'explicit' }, { checkTombstone: false }); const explicit = await database.getSessionV2(session); assert.deepStrictEqual({ @@ -316,22 +482,21 @@ suite('AgentHostDatabase sessions_v2', () => { }); }); - test('mirrors legacy external provenance backfill without a catalog revision', async () => { + 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.registerSession(session, { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); await database.close(); database = undefined; const rawDatabase = await openDatabase(path); - await exec(rawDatabase, `UPDATE sessions SET external = NULL WHERE session_uri = '${session}'; - UPDATE sessions_v2 SET external = NULL WHERE session_uri = '${session}'`); + await exec(rawDatabase, `UPDATE sessions_v2 SET external = NULL WHERE session_uri = '${session}'`); await close(rawDatabase); database = new AgentHostDatabase(path); - await database.updateSessionExternal([{ session, external: true }]); + await database.updateSessionV2External([{ session, external: true }]); const row = await database.getSessionV2(session); assert.deepStrictEqual(row && { @@ -345,38 +510,162 @@ suite('AgentHostDatabase sessions_v2', () => { }); }); - test('legacy deletion cascades and legacy insertion needs no new columns', async () => { + 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', + }); + + 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, external: true, source: 'discovery' }, + current: { session, provider: 'copilot', startTime: 10, 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, external: false, source: 'explicit' }, + current: { session, provider: 'claude', startTime: 10, 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(createProjection(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, + external: current.external, + source: current.source, + }, + sourceRevision: current?.sourceRevision, + }, { + legacy: { session, provider: 'copilot', startTime: 1, external: true, source: 'discovery' }, + current: { session, provider: 'copilot', startTime: 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); - await database.registerSession('session://deleted', { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); - await database.upsertSessionV2(createProjection('session://deleted', 'generation-1', 1), undefined); - await database.unregisterSession('session://deleted'); + const currentOnly = 'session://current-only'; + await database.registerSessionV2(currentOnly, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.upsertSessionV2(createProjection(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')`); - const deletedRows = await all(oldBuildDatabase, `SELECT session_uri FROM sessions_v2 WHERE session_uri = 'session://deleted'`); await close(oldBuildDatabase); database = new AgentHostDatabase(path); assert.deepStrictEqual({ - deletedRows, + currentOnlyLegacy: await database.getSession(currentOnly), + currentOnlyV2: await database.getSessionV2(currentOnly), oldBuildSession: await database.getSession('session://old-build'), - oldBuildSessionV2: await database.getSessionV2('session://old-build'), + oldBuildSessionV2: await database.getSessionV2Registration('session://old-build'), }, { - deletedRows: [], + currentOnlyLegacy: undefined, + currentOnlyV2: { + ...createProjection(currentOnly, 'generation-1', 1), + provider: 'copilot', + startTime: 1, + external: false, + source: 'explicit', + }, oldBuildSession: { session: 'session://old-build', provider: 'copilot', startTime: 2, external: true, source: 'discovery' }, oldBuildSessionV2: undefined, }); }); - test('does not surface v2 orphans deleted by an old connection with foreign keys disabled', async () => { + 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.registerSession(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); await database.close(); database = undefined; @@ -393,24 +682,171 @@ suite('AgentHostDatabase sessions_v2', () => { list: await database.listSessionsV2(), }, { orphanRows: [{ session_uri: session }], - get: undefined, - list: [], + get: { + ...createProjection(session, 'generation-1', 1), + provider: 'copilot', + startTime: 1, + external: false, + source: 'explicit', + }, + list: [{ + ...createProjection(session, 'generation-1', 1), + provider: 'copilot', + startTime: 1, + external: false, + source: 'explicit', + }], }); }); - test('does not surface a verified row while its legacy session is tombstoned', async () => { + test('tombstone prevents current import and explicit recreation clears it', async () => { database = new AgentHostDatabase(':memory:'); const session = 'session://tombstoned-read'; - await database.registerSession(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); - await database.markSessionTombstoned(session); + 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({ - get: await database.getSessionV2(session), - list: await database.listSessionsV2(), + 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, external: false, source: 'explicit' }, + complete: undefined, + }); + }); + + test('projection-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, 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(createProjection(session, 'generation-1', 1), undefined); + await database.excludeSessionV2({ + provider: 'copilot', + session, + reason: 'staleExternal', + fingerprint: '123', + }); + + 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, 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(createProjection(excluded, 'generation-1', 1), undefined); + + await database.excludeSessionV2({ + provider: 'copilot', + session: excluded, + reason: 'staleExternal', + fingerprint: '1', + }); + const excludedUpsert = await database.upsertSessionV2(createProjection(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), }, { - get: undefined, - list: [], + excludedRegistration: undefined, + excludedMarker: { provider: 'copilot', session: excluded, reason: 'staleExternal', fingerprint: '1' }, + excludedUpsert: 'missingSession', + registeredIdentity: { session: registered, provider: 'copilot', startTime: 2, external: true, source: 'discovery' }, + staleMarker: undefined, }); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 1b37b3241f42be..22670dd9f20eac 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -46,7 +46,7 @@ import { ChatInteractivity, type MessageAttachment } from '../../common/state/pr import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; -import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; import type { AgentHostCatalogReconciliationSourceResult } from '../../node/agentHostCatalogReconciliationService.js'; import type { AgentHostCatalogReadMode, IAgentHostCatalogShadowValidationReport, IAgentHostCatalogShadowValidationReporter } from '../../node/agentHostCatalogShadowValidator.js'; import { AGENT_HOST_CATALOG_PROJECTION_VERSION, projectAgentHostCatalog, type IAgentHostCatalogSource } from '../../node/agentHostCatalogProjection.js'; @@ -125,8 +125,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[] } { const databases = new Map(); + const databaseOpens: string[] = []; const database = (session: URI): TestSessionDatabase => { const key = session.toString(); let result = databases.get(key); @@ -139,14 +140,62 @@ 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, + }; +} + +async function seedVerifiedSessionV2(database: IAgentHostDatabase, sessionData: TestSessionDatabase, session: URI, external: boolean, isRead = true): Promise { + const provider = AgentSession.provider(session); + assert.ok(provider); + const source: IAgentHostCatalogSource = { + modifiedTime: 1, + title: 'verified', + isRead, + isArchived: false, + workspaceless: false, + workingDirectories: [], + chats: [{ uri: buildDefaultChatUri(session), order: 0, kind: 'default' }], }; + const projection = projectAgentHostCatalog(source, { + session: session.toString(), + sessionGeneration: 'verified-generation', + sourceRevision: 0, + }); + assert.strictEqual(projection.ok, true); + if (!projection.ok) { + return; + } + await database.registerSessionV2(session.toString(), { + provider, + startTime: 1, + source: external ? 'discovery' : 'restore', + }, { checkTombstone: true }); + await sessionData.setMetadataValuesAndCatalogSyncSnapshot({}, { + sessionGeneration: projection.value.catalog.sessionGeneration, + sourceRevision: projection.value.catalog.sourceRevision, + projectionVersion: projection.value.catalog.projectionVersion, + payload: projection.value.sourcePayload, + payloadHash: projection.value.catalog.sourceHash, + state: 'pending', + }); + assert.strictEqual(await database.upsertSessionV2(projection.value.catalog, undefined), 'applied'); + assert.strictEqual(await sessionData.acknowledgeCatalogSyncSnapshot({ + sessionGeneration: projection.value.catalog.sessionGeneration, + sourceRevision: projection.value.catalog.sourceRevision, + projectionVersion: projection.value.catalog.projectionVersion, + payloadHash: projection.value.catalog.sourceHash, + }), true); } function sessionConfigToChatOptions(config: IAgentCreateSessionConfig): IAgentCreateChatOptions { @@ -230,20 +279,35 @@ 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(); registryWriteAttempts = 0; private _remainingRegistryWriteFailures = 0; - private readonly _sessionsWithoutExternal = new Set(); 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 }); + } + + setSessionV2ProjectionVersion(session: URI, projectionVersion: number): 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, projectionVersion }); } failRegistryWrites(count: number): void { @@ -273,7 +337,6 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { async unregisterSession(session: string): Promise { this._beforeWrite(); this._sessions.delete(session); - this._sessionsV2.delete(session); this._agentMergeEnabled.delete(session); } @@ -281,6 +344,7 @@ 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); } @@ -289,7 +353,7 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { 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, @@ -301,14 +365,11 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { 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 { @@ -333,6 +394,40 @@ 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): Promise { + this._beforeWrite(); + this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); + this._sessionV2Registrations.delete(exclusion.session); + this._sessionsV2.delete(exclusion.session); + } + + 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); } @@ -347,6 +442,61 @@ 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, source } = sessionOptions; + const existing = this._sessionV2Registrations.get(session) ?? this._sessions.get(session); + const inserted = { session, provider, startTime, external: source === 'discovery', source }; + const registration = source === 'explicit' + ? { ...inserted, startTime: existing?.startTime ?? startTime } + : 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._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); @@ -359,10 +509,73 @@ 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, source } = sessionOptions; + const existing = this._sessionV2Registrations.get(session); + this._sessionV2Registrations.set(session, existing ?? { session, provider, startTime, 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++; + this._sessionV2Registrations.set(session, legacy); + const projection = this._sessionsV2.get(session); + if (projection) { + this._sessionsV2.set(session, { ...projection, ...legacy }); + } + } + + 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 upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { - const session = this._sessions.get(projection.session); + this.sessionV2UpsertAttempts++; + const session = this._sessionV2Registrations.get(projection.session); if (!session) { return 'missingSession'; } @@ -389,8 +602,11 @@ 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 _backfilled = false; @@ -411,13 +627,13 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { async unregisterSession(session: string): Promise { this._sessions.delete(session); - this._sessionsV2.delete(session); this._agentMergeEnabled.delete(session); } 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); } @@ -452,6 +668,36 @@ 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): Promise { + this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); + this._sessionV2Registrations.delete(exclusion.session); + this._sessionsV2.delete(exclusion.session); + } + + 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); } @@ -464,6 +710,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); @@ -476,6 +751,62 @@ 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, source } = sessionOptions; + const existing = this._sessionV2Registrations.get(session); + this._sessionV2Registrations.set(session, existing ?? { session, provider, startTime, 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); + } + + 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 { + this._sessionV2Registrations.set(session, legacy); + const projection = this._sessionsV2.get(session); + if (projection) { + this._sessionsV2.set(session, { ...projection, ...legacy }); + } + } + + 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); @@ -485,7 +816,7 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { return [...this._sessionsV2.values()]; } async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { - const session = this._sessions.get(projection.session); + const session = this._sessionV2Registrations.get(projection.session); if (!session) { return 'missingSession'; } @@ -2970,7 +3301,7 @@ suite('AgentService (node dispatcher)', () => { override async getSessionV2(session: string): Promise { const catalog = this._catalogs.get(session); - const registered = await this.getSession(session); + const registered = await this.getSessionV2Registration(session); return catalog && registered ? { ...registered, ...catalog } : undefined; } } @@ -3125,14 +3456,14 @@ suite('AgentService (node dispatcher)', () => { 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.registerSession(session.toString(), { + await orchestratorDatabase.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 10, source: 'explicit', }, { checkTombstone: false }); orchestratorDatabase.setCatalog(session, centralSource(20, 'Central')); const backingSession = AgentSession.uri('copilot', 'central-backing'); - await orchestratorDatabase.registerSession(backingSession.toString(), { + await orchestratorDatabase.registerSessionV2(backingSession.toString(), { provider: 'copilot', startTime: 11, source: 'explicit', @@ -3173,7 +3504,7 @@ suite('AgentService (node dispatcher)', () => { for (const readMode of ['centralWithFallback', 'central'] as const) { const orchestratorDatabase = new CentralCatalogDatabase(); const session = AgentSession.uri('copilot', `central-adoptable-${readMode}`); - await orchestratorDatabase.registerSession(session.toString(), { + await orchestratorDatabase.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 10, source: 'explicit', @@ -3240,7 +3571,7 @@ suite('AgentService (node dispatcher)', () => { const centralSession = AgentSession.uri('copilot', 'eligible'); const fallbackSession = AgentSession.uri('copilot', 'fallback'); for (const session of [centralSession, fallbackSession]) { - await orchestratorDatabase.registerSession(session.toString(), { + await orchestratorDatabase.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 10, source: 'explicit', @@ -3287,7 +3618,7 @@ suite('AgentService (node dispatcher)', () => { const eligible = AgentSession.uri('copilot', 'provider-unavailable'); const ineligible = AgentSession.uri('copilot', 'missing-catalog'); for (const session of [eligible, ineligible]) { - await orchestratorDatabase.registerSession(session.toString(), { + await orchestratorDatabase.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 10, source: 'explicit', @@ -3360,7 +3691,7 @@ suite('AgentService (node dispatcher)', () => { for (let index = 0; index < 12; index++) { const session = AgentSession.uri('copilot', `external-${index}`); sessions.push(session); - await orchestratorDatabase.registerSession(session.toString(), { + await orchestratorDatabase.registerSessionV2(session.toString(), { provider: 'copilot', startTime: index, source: 'discovery', @@ -3383,7 +3714,7 @@ suite('AgentService (node dispatcher)', () => { test('central fallback returns without waiting for scheduled reconciliation', async () => { const orchestratorDatabase = new CentralCatalogDatabase(); const session = AgentSession.uri('copilot', 'repair-later'); - await orchestratorDatabase.registerSession(session.toString(), { + await orchestratorDatabase.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 10, source: 'explicit', @@ -3772,11 +4103,11 @@ suite('AgentService (node dispatcher)', () => { const at = (hourOfDay: number) => now - (18 - hourOfDay) * hour; const database = new TransientRegistryWriteDatabase(); for (const [id, startTime] of [['external-morning', at(10)], ['external-afternoon', at(16)]] as const) { - await database.registerSession(AgentSession.uri('copilot', id).toString(), { provider: 'copilot', startTime, source: 'discovery' }, { checkTombstone: true }); + await database.registerSessionV2(AgentSession.uri('copilot', id).toString(), { provider: 'copilot', startTime, source: 'discovery' }, { checkTombstone: true }); } // Registered under a provider that is never registered with the service. for (const [id, startTime] of [['local-11am', at(11)], ['local-5pm', at(17)]] as const) { - await database.registerSession(AgentSession.uri('claude', id).toString(), { provider: 'claude', startTime, source: 'restore' }, { checkTombstone: true }); + await database.registerSessionV2(AgentSession.uri('claude', id).toString(), { provider: 'claude', startTime, source: 'restore' }, { checkTombstone: true }); } await database.markProviderBackfilled('copilot'); @@ -4028,7 +4359,7 @@ 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'); @@ -4510,6 +4841,871 @@ suite('AgentService (node dispatcher)', () => { ); }); + suite('sessions_v2 direct importer', () => { + class DirectImportAgent extends MockAgent { + catalog: readonly IAgentChatMetadata[] | undefined = []; + catalogCalls = 0; + metadataCalls = 0; + adoptionCalls = 0; + + override async listChatsToMigrate(): Promise { + this.catalogCalls++; + return this.catalog; + } + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + this.metadataCalls++; + return super.getChatMetadata(chat, context); + } + + async ensureChatAdopted(): Promise { + this.adoptionCalls++; + return { adopted: true, eligible: true }; + } + } + + 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('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)]; + svc.registerProvider(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_PROJECTION_VERSION), + oldGlobalMarker: await database.isSessionRegistryBackfilled(), + oldProviderMarker: await database.isProviderBackfilled('copilot'), + }, { + legacy: [], + currentRegistrations: [providerOnly.toString()], + currentCatalog: [providerOnly.toString()], + currentMarker: true, + oldGlobalMarker: false, + oldProviderMarker: false, + }); + }); + + 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_PROJECTION_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, external: true, source: 'discovery' }, + current: { session: discovered.toString(), provider: 'copilot', 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 changed = await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [ + { ...metadata(registered), 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, 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')); + + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [discoveredChat(session)]); + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [discoveredChat(session)]); + + assert.deepStrictEqual({ + legacy: await database.getSession(session.toString()), + current: await database.getSessionV2Registration(session.toString()), + registryWriteAttempts: database.registryWriteAttempts, + }, { + legacy: { session: session.toString(), provider: 'copilot', startTime: 1, 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, external: false, source: 'explicit' }, + current: { session: session.toString(), provider: 'copilot', startTime: 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)]; + svc.registerProvider(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: (await database.getSessionV2(session.toString()))?.isRead, + }, { + persistedRead: '', + catalogRead: false, + }); + }); + + 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, + external: false, + source: 'explicit', + }); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = [metadata(legacy)]; + svc.registerProvider(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)]; + svc.registerProvider(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_PROJECTION_VERSION), + catalogCalls: agent.catalogCalls, + }, { + registrations: [verified.toString(), incomplete.toString(), missing.toString()].sort(), + catalog: [verified.toString(), incomplete.toString(), missing.toString()].sort(), + verifiedGeneration: 'verified-generation', + missingRevisions: [0, 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)]; + svc.registerProvider(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_PROJECTION_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)]; + first.registerProvider(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_PROJECTION_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_PROJECTION_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, + external: current.external, + source: current.source, + }, + sourceRevision: current?.sourceRevision, + upsertAttempts: database.sessionV2UpsertAttempts, + catalogCalls: agent.catalogCalls, + }, { + identity: { provider: 'copilot', startTime: 1, external: true, source: 'discovery' }, + sourceRevision: 0, + upsertAttempts: 1, + catalogCalls: 0, + }); + }); + + 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, + external: false, + source: 'explicit', + }); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_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, 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, + external: false, + source: 'explicit', + }); + await database.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: true }); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_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, external: current.external, source: current.source }, + legacy: await database.getSession(session.toString()), + sourceRevision: current?.sourceRevision, + upsertAttempts: database.sessionV2UpsertAttempts, + }, { + current: { startTime: 5, external: false, source: 'explicit' }, + legacy: { session: session.toString(), provider: 'copilot', startTime: 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_PROJECTION_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_PROJECTION_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 projection 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, ''); + database.setSessionV2ProjectionVersion(session, AGENT_HOST_CATALOG_PROJECTION_VERSION - 1); + await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_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); + (svc as unknown as { _providers: Map })._providers.set(agent.id, agent); + agent.catalog = undefined; + + await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); + + const projection = await database.getSessionV2(session.toString()); + assert.deepStrictEqual({ + projectionVersion: projection?.projectionVersion, + isRead: projection?.isRead, + catalogCalls: agent.catalogCalls, + }, { + projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + isRead: false, + catalogCalls: 0, + }); + }); + + test('restores side effects for newly imported external, host-created, and 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(), adoptable.toString()], + 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, + openDatabase: session => { + if (failOneSession && session.toString() === failing.toString()) { + throw new Error('transient per-session failure'); + } + return perSession.service.openDatabase(session); + }, + }; + const svc = createService(database, sessionData); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.catalog = undefined; + svc.registerProvider(agent); + await assert.rejects(svc.listSessions(), /cannot enumerate its native session catalog yet/); + const markerWhileUnavailable = await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_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_PROJECTION_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_PROJECTION_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 = []; + svc.registerProvider(agent); + + await svc.listSessions(); + 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_PROJECTION_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: 1, databaseOpens: [] }, + revivedExclusion: undefined, + revived: absent.toString(), + }); + }); + + 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_PROJECTION_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 }]; + svc.registerProvider(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)), + ]; + svc.registerProvider(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: row.ehcliAdoptable, + adopted: row.ehcliAdopted, + })), + 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_PROJECTION_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(); @@ -4882,7 +6078,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'); @@ -4900,8 +6096,11 @@ suite('AgentService (node dispatcher)', () => { test('surfaces registered adoptable legacy metadata directly from the provider catalog', 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 { @@ -4911,11 +6110,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) }; - svc.registerProvider(agent); + agent.catalog = [{ ...discoveredChat(legacy), _meta: withSessionEhcliAdoptable(undefined) }]; getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - await svc.listSessions(); + svc.registerProvider(agent); + await (svc as unknown as { _awaitInitialProviderMigrationForProvider(provider: IAgent): Promise })._awaitInitialProviderMigrationForProvider(agent); const surfaced = getStateManager(svc).getSurfacedSessionSummary(legacy.toString()); assert.deepStrictEqual({ @@ -5115,7 +6313,7 @@ suite('AgentService (node dispatcher)', () => { shadowReports, }, { registryWrites: writesBeforeUnavailable, - registered: [existing.toString()], + registered: [], shadowReports: 0, }); @@ -5210,7 +6408,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')); @@ -5232,8 +6430,8 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION), + claude: await db.isSessionsV2Backfilled('claude', AGENT_HOST_CATALOG_PROJECTION_VERSION), }, first: first.map(session => session.session.toString()).sort(), second: second.map(session => session.session.toString()).sort(), @@ -5311,7 +6509,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 @@ -5322,7 +6520,7 @@ suite('AgentService (node dispatcher)', () => { const early = disposables.add(new MockAgent('copilot')); svc.registerProvider(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 @@ -5330,7 +6528,7 @@ suite('AgentService (node dispatcher)', () => { const late = disposables.add(new MockAgent('claude')); svc.registerProvider(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 @@ -5474,7 +6672,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()]); }); @@ -7675,16 +8873,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_PROJECTION_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(); @@ -8016,7 +9214,8 @@ suite('AgentService (node dispatcher)', () => { const git: ISessionGitState = { branchName: 'external', outgoingChanges: 2, githubOwner: 'owner', githubRepo: 'repo' }; const session = AgentSession.uri('copilot', 'external-git'); const internals = localService as unknown as { - _initializeExternalSessionReadState(metadata: IAgentSessionMetadata): Promise; + _buildImportedCatalogSyncRequest(provider: IAgent, metadata: IAgentSessionMetadata, external: boolean, seedExternalRead: boolean): Promise; + _catalogSyncService: import('../../node/agentHostCatalogSyncService.js').AgentHostCatalogSyncService; _sessionRegistry: AgentSessionRegistry; }; await internals._sessionRegistry.register(session, { @@ -8025,13 +9224,14 @@ suite('AgentService (node dispatcher)', () => { source: 'discovery', }, { checkTombstone: false }); - await internals._initializeExternalSessionReadState({ + const metadata: IAgentSessionMetadata = { session, startTime: 1, modifiedTime: 2, summary: 'External', _meta: withSessionGitState(undefined, git), - }); + }; + await internals._catalogSyncService.synchronize(session, await internals._buildImportedCatalogSyncRequest(copilotAgent, metadata, true, true)); const snapshot = await db.getCatalogSyncSnapshot(); const central = await (localService as unknown as { _orchestratorDatabase: IAgentHostDatabase })._orchestratorDatabase.getSessionV2(session.toString()); @@ -8048,12 +9248,12 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('catalog reconciliation replaces an orphaned projection v2 receipt with a fresh current generation', async () => { + test('catalog reconciliation replaces an orphaned projection v2 receipt without diverging from importer metadata', async () => { const db = new TestSessionDatabase(); const catalogDatabase = new TransientRegistryWriteDatabase(); const session = AgentSession.uri('copilot', 'projection-v2'); await createAgentSession(copilotAgent, { session }); - await catalogDatabase.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); + await catalogDatabase.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); const oldSnapshot: ISessionCatalogSyncPendingSnapshot = { sessionGeneration: 'test-generation', sourceRevision: 7, @@ -8065,21 +9265,38 @@ suite('AgentService (node dispatcher)', () => { await db.setMetadataValuesAndCatalogSyncSnapshot({}, oldSnapshot); await db.acknowledgeCatalogSyncSnapshot(oldSnapshot); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, catalogDatabase)); + const reconciliation = (localService as unknown as { + _catalogReconciliationService: { + schedule(): void; + runPass(): Promise; + }; + })._catalogReconciliationService; + reconciliation.schedule = () => { }; localService.registerProvider(copilotAgent); - await localService.whenCatalogReconciliationIdle(); + await (localService as unknown as { + _awaitInitialProviderMigrationForProvider(provider: IAgent): Promise; + })._awaitInitialProviderMigrationForProvider(copilotAgent); + const imported = await catalogDatabase.getSessionV2(session.toString()); + const writesAfterImport = catalogDatabase.sessionV2UpsertAttempts; + await reconciliation.runPass(); const upgraded = await db.getCatalogSyncSnapshot(); + const reconciled = await catalogDatabase.getSessionV2(session.toString()); assert.deepStrictEqual({ projectionVersion: upgraded?.projectionVersion, sourceRevision: upgraded?.sourceRevision, generationChanged: upgraded?.sessionGeneration !== oldSnapshot.sessionGeneration, state: upgraded?.state, + writes: [writesAfterImport, catalogDatabase.sessionV2UpsertAttempts], + hashStable: imported !== undefined && reconciled?.sourceHash === imported.sourceHash, }, { projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, sourceRevision: 0, generationChanged: true, state: 'acknowledged', + writes: [1, 1], + hashStable: true, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index e4405cec88aaac..b937a6c868ef8b 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, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Projection } 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; @@ -109,6 +111,42 @@ 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 { + this._throwWriteFailure(); + this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); + this.sessions.delete(exclusion.session); + } + + 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); @@ -124,6 +162,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) { @@ -138,6 +192,42 @@ 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); + } + + 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 upsertSessionV2(_projection: IAgentHostDatabaseSessionV2Projection, _expectedSessionGeneration: string | undefined): Promise { return 'missingSession'; } @@ -204,6 +294,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; @@ -285,6 +390,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, external: false, source: 'explicit' }, + current: { session: a.toString(), provider: 'copilot', startTime: 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 the first-observed startTime', async () => { const registry = createRegistry(); await registerExplicit(registry, a, 'copilot', 100); @@ -403,6 +530,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(); @@ -514,6 +670,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/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index daf729441cf042..37dc64b62a170b 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 @@ -124,10 +124,11 @@ retention when monitoring ends. ### Host session catalog -The local Agent Host maintains a host-wide SQLite catalog beside its registry. -The catalog stores only bounded list-visible session and chat metadata. Per-session -databases continue to own turns, drafts, annotations, detailed changesets, and -opaque provider backing required when a session or chat is opened. +The local Agent Host maintains a host-wide `sessions_v2` SQLite registry and +catalog. Each row contains current registry identity plus bounded list-visible +session and chat metadata. Per-session databases continue to own turns, drafts, +annotations, detailed changesets, and opaque provider backing required when a +session or chat is opened. Catalog persistence is legacy-first during the compatibility window: one per-session transaction updates downgrade-compatible metadata and a durable @@ -142,13 +143,26 @@ 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. -The central catalog stores one validated `sessions_v2` row per registered -session. An upsert atomically replaces the complete row and is guarded by the -session incarnation and source revision. Concurrent first writers converge on -the winning incarnation through a serialized retry, while tombstones and the -registry join prevent stale work or orphaned rows from resurfacing deleted -sessions. Runtime rollback selects legacy read mode; no retained central -generation is required. +`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. Projection-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 complete v2 row and is guarded by the session +incarnation and source revision. Concurrent first writers converge on the +winning incarnation through a serialized retry. Runtime rollback selects legacy +read mode; no retained central generation is required. Each row also persists top-level eligibility. Chat-backing sessions therefore remain hidden after restart without opening their per-session database. For From 1f4d679c5a99c720f8dd1388fbd71dc927d62e53 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Mon, 24 Aug 2026 21:07:06 +0200 Subject: [PATCH 03/30] agentHost: extract session catalog helpers Move catalog source resolution and downgrade-compatible peer chat persistence out of AgentService into focused helpers without changing migration or runtime behavior.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostCatalogSourceResolver.ts | 343 +++++++++++ .../agentHost/node/agentHostPeerChatStore.ts | 173 ++++++ .../platform/agentHost/node/agentService.ts | 544 +----------------- .../agentHostCatalogSourceResolver.test.ts | 218 +++++++ .../test/node/agentHostPeerChatStore.test.ts | 132 +++++ .../agentHost/test/node/agentService.test.ts | 8 +- 6 files changed, 902 insertions(+), 516 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts create mode 100644 src/vs/platform/agentHost/node/agentHostPeerChatStore.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts new file mode 100644 index 00000000000000..ab05b26cb70812 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -0,0 +1,343 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { parseSessionArtifacts, readSessionArtifacts, stringifySessionArtifacts } from '../common/sessionArtifacts.js'; +import { META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; +import { GIT_DB_METADATA_KEYS, 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_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ISessionGitHubState, ISessionGitState, ISessionSourceControlState, parseSessionFolderPickerDecision, parseSessionMultiRootMetadata, parseSessionOrchestration, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionOrchestration, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SessionStatus, SessionSummary } from '../common/state/sessionState.js'; +import { AgentHostCatalogJsonValue, IAgentHostCatalogSource, projectAgentHostCatalog } 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?: AgentHostCatalogJsonValue; + }[]; +} + +export interface IAgentHostCatalogSourceResolverDependencies { + readonly openDatabase: (session: URI) => { + readonly object: { + getMetadataObject>(keys: T): Promise<{ [K in keyof T]: string | undefined }>; + }; + dispose(): void; + }; + readonly isUnpersistedChatBacking: (session: URI) => boolean; + readonly worktreeProjectFromRepositoryRoot: (repositoryRoot: string | undefined) => { readonly uri: URI; readonly displayName: string } | undefined; +} + +export class AgentHostCatalogSourceResolver { + + constructor(private readonly _dependencies: IAgentHostCatalogSourceResolverDependencies) { } + + async buildCatalogSyncRequest(session: URI, state: ICatalogSourceState, metadataOverrides: Readonly>, preferPersistedMetadata: boolean): Promise { + const metadataKeys: Record = { + [SESSION_CUSTOM_TITLE_KEY]: true, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: true, + [AH_META_IS_READ_DB_KEY]: true, + [AH_META_IS_ARCHIVED_DB_KEY]: true, + [AH_META_IS_DONE_DB_KEY]: true, + [AH_META_ORCHESTRATION_DB_KEY]: true, + [AH_META_WORKSPACELESS_DB_KEY]: true, + [AH_META_EHCLI_ADOPTED_DB_KEY]: true, + [SESSION_META_MULTI_ROOT_KEY]: true, + [SESSION_META_FOLDER_PICKER_KEY]: true, + [SESSION_ARTIFACTS_KEY]: true, + [META_CHANGES_SUMMARY]: true, + [CHAT_BACKING_METADATA_KEY]: true, + [WORKTREE_META_REPOSITORY_ROOT]: true, + ...GIT_DB_METADATA_KEYS, + }; + for (const chat of state.chats) { + metadataKeys[customChatTitleMetadataKey(chat.uri)] = true; + metadataKeys[customChatTitleSourceMetadataKey(chat.uri)] = true; + } + + const ref = this._dependencies.openDatabase(session); + let persisted: { readonly [key: string]: string | undefined }; + try { + persisted = await ref.object.getMetadataObject(metadataKeys); + } finally { + ref.dispose(); + } + const metadata = { ...persisted, ...metadataOverrides }; + const title = (preferPersistedMetadata ? metadata[SESSION_CUSTOM_TITLE_KEY] : metadataOverrides[SESSION_CUSTOM_TITLE_KEY]) ?? state.title ?? ''; + const titleSource = normalizeCatalogTitleSource(metadata[SESSION_CUSTOM_TITLE_SOURCE_KEY]); + const persistedMultiRoot = metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined + ? parseSessionMultiRootMetadata(metadata[SESSION_META_MULTI_ROOT_KEY]) + : undefined; + const multiRoot = preferPersistedMetadata + ? (metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined ? persistedMultiRoot : readSessionMultiRootMetadata(state.meta)) + : readSessionMultiRootMetadata(state.meta) ?? persistedMultiRoot; + const persistedFolderPicker = metadata[SESSION_META_FOLDER_PICKER_KEY] !== undefined + ? parseSessionFolderPickerDecision(metadata[SESSION_META_FOLDER_PICKER_KEY]) + : undefined; + const folderPicker = preferPersistedMetadata + ? (metadata[SESSION_META_FOLDER_PICKER_KEY] !== undefined ? persistedFolderPicker : readSessionFolderPickerDecision(state.meta)) + : readSessionFolderPickerDecision(state.meta) ?? persistedFolderPicker; + const persistedArtifacts = parseSessionArtifacts(metadata[SESSION_ARTIFACTS_KEY]); + 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 persistedOrchestration = metadata[AH_META_ORCHESTRATION_DB_KEY] !== undefined + ? parseSessionOrchestration(metadata[AH_META_ORCHESTRATION_DB_KEY]) + : undefined; + const orchestration = preferPersistedMetadata + ? (metadata[AH_META_ORCHESTRATION_DB_KEY] !== undefined ? persistedOrchestration : readSessionOrchestration(state.meta)) + : (metadataOverrides[AH_META_ORCHESTRATION_DB_KEY] !== undefined ? persistedOrchestration : readSessionOrchestration(state.meta) ?? persistedOrchestration); + const persistedGitHub = metadata[META_GITHUB_STATE] !== undefined + ? readPersistedGitHubState(metadata[META_GITHUB_STATE]) + : undefined; + const github = preferPersistedMetadata + ? (metadata[META_GITHUB_STATE] !== undefined ? persistedGitHub : readSessionGitHubState(state.meta)) + : readSessionGitHubState(state.meta) ?? persistedGitHub; + const persistedSourceControl = metadata[META_SOURCE_CONTROL_STATE] !== undefined + ? readPersistedSourceControlState(metadata[META_SOURCE_CONTROL_STATE]) + : undefined; + const sourceControl = preferPersistedMetadata + ? (metadata[META_SOURCE_CONTROL_STATE] !== undefined ? persistedSourceControl : readSessionSourceControlState(state.meta)) + : readSessionSourceControlState(state.meta) ?? persistedSourceControl; + const persistedGit = metadata[META_GIT_STATE] !== undefined + ? readPersistedGitState(metadata[META_GIT_STATE]) + : undefined; + const git = readSessionGitState(state.meta) ?? persistedGit; + const persistedWorkspaceless = metadata[AH_META_WORKSPACELESS_DB_KEY] === 'true'; + 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 + ? metadata[AH_META_IS_READ_DB_KEY] === 'true' + : stateIsRead; + const persistedArchived = metadata[AH_META_IS_ARCHIVED_DB_KEY] ?? metadata[AH_META_IS_DONE_DB_KEY]; + const isArchived = preferPersistedMetadata && persistedArchived !== undefined + ? persistedArchived === 'true' + : (state.status & SessionStatus.IsArchived) !== 0; + const persistedChanges = metadata[META_CHANGES_SUMMARY] !== undefined + ? readPersistedChanges(metadata[META_CHANGES_SUMMARY]) + : undefined; + const changes = preferPersistedMetadata && metadata[META_CHANGES_SUMMARY] !== undefined ? persistedChanges : state.changes; + const worktreeProject = this._dependencies.worktreeProjectFromRepositoryRoot(metadata[WORKTREE_META_REPOSITORY_ROOT]); + const source: IAgentHostCatalogSource = { + modifiedTime: state.modifiedTime, + title: title || undefined, + titleSource, + isRead, + isArchived, + project: worktreeProject + ? { uri: worktreeProject.uri.toString(), displayName: worktreeProject.displayName } + : state.project, + workspaceless, + isChatBacking: !!metadata[CHAT_BACKING_METADATA_KEY] || this._dependencies.isUnpersistedChatBacking(session), + ehcliAdoptable: readSessionEhcliAdoptable(state.meta), + ehcliAdopted: readSessionEhcliAdopted(state.meta) || metadata[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true', + multiRoot, + folderPicker, + changes, + github, + git, + sourceControl, + artifacts, + orchestration, + workingDirectories: state.workingDirectories, + chats: state.chats.map((chat, order) => ({ + uri: chat.uri, + order, + kind: chat.kind, + title: (preferPersistedMetadata ? metadata[customChatTitleMetadataKey(chat.uri)] : metadataOverrides[customChatTitleMetadataKey(chat.uri)]) || chat.title || undefined, + titleSource: normalizeCatalogTitleSource(metadata[customChatTitleSourceMetadataKey(chat.uri)]), + origin: chat.origin, + })), + }; + const legacyMetadata: Record = { + ...metadataOverrides, + [AH_META_IS_READ_DB_KEY]: source.isRead ? 'true' : '', + [AH_META_IS_ARCHIVED_DB_KEY]: source.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), + [AH_META_ORCHESTRATION_DB_KEY]: orchestration ? JSON.stringify(orchestration) : '', + }; + if (source.workspaceless || metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { + legacyMetadata[AH_META_WORKSPACELESS_DB_KEY] = source.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 (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 { source, legacyMetadata }; + } +} + +export function toCatalogJsonValue(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 = toCatalogJsonValue(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 = toCatalogJsonValue(entry); + if (converted !== undefined) { + result[key] = converted; + } + } + return result; + } + return undefined; +} + +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 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 { + const projected = projectAgentHostCatalog({ + modifiedTime: 0, + isRead: false, + isArchived: false, + workspaceless: false, + git: JSON.parse(value), + workingDirectories: [], + chats: [], + }, { + session: 'agent-host-catalog-git-validation', + sessionGeneration: 'agent-host-catalog-git-validation', + sourceRevision: 0, + }); + return projected.ok ? projected.value.source.git : undefined; + } catch { + return undefined; + } +} + +function readPersistedChanges(value: string | undefined): ChangesSummary | undefined { + if (!value) { + return undefined; + } + try { + return JSON.parse(value) as ChangesSummary; + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts new file mode 100644 index 00000000000000..278b5c546be153 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { ChatOrigin } from '../common/state/protocol/state.js'; +import { isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; +import { fromCatalogChatOrigin, toCatalogJsonValue } from './agentHostCatalogSourceResolver.js'; + +export const PEER_CHATS_METADATA_KEY = 'peerChats'; + +export interface IPersistedPeerChat { + readonly uri: string; + readonly providerData?: string; + readonly origin?: ChatOrigin; +} + +export class AgentHostPeerChatStore { + + private readonly _writes = new Map>(); + + constructor( + private readonly _sessionDataService: ISessionDataService, + private readonly _logService: ILogService, + ) { } + + /** + * Missing or malformed data returns `undefined`; `[]` is an explicit empty sentinel. + */ + async tryRead(session: URI, batched = false): Promise { + const ref = 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 this._parse(session, raw); + } catch (error) { + this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(error)}`); + return undefined; + } finally { + 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]); + } + + upsert(session: URI, chat: URI, providerData: string | undefined, origin?: ChatOrigin): Promise { + const chatUri = chat.toString(); + return this._enqueueWrite(session, entries => { + const existing = entries.find(entry => entry.uri === chatUri); + const effectiveOrigin = origin ?? existing?.origin; + const next = entries.filter(entry => entry.uri !== chatUri); + next.push({ + uri: chatUri, + ...(providerData !== undefined ? { providerData } : {}), + ...(effectiveOrigin !== undefined ? { origin: effectiveOrigin } : {}), + }); + return next; + }); + } + + remove(session: URI, chat: URI): Promise { + const chatUri = chat.toString(); + return this._enqueueWrite(session, entries => entries.filter(entry => entry.uri !== chatUri)); + } + + private _enqueueWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { + const key = session.toString(); + const previous = this._writes.get(key) ?? Promise.resolve(); + const next = previous + .catch(() => { /* a failed prior write must not block later ones */ }) + .then(() => this._applyWrite(session, mutate)); + 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 { + const ref = this._sessionDataService.openDatabase(session); + try { + let current: IPersistedPeerChat[] = []; + try { + const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); + if (raw !== undefined) { + current = this._parse(session, raw); + } + } catch (error) { + this._logService.warn(`[AgentService] Replacing malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(error)}`); + } + const updated = this._parse(session, JSON.stringify(mutate(current))); + await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated)); + } finally { + ref.dispose(); + } + } + + private _parse(session: URI, raw: string): IPersistedPeerChat[] { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + throw new Error('expected an array'); + } + const sessionKey = session.toString(); + const seen = new Set(); + const result: IPersistedPeerChat[] = []; + for (let index = 0; index < parsed.length; 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; + } + const originValue = toCatalogJsonValue(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 } : {}), + }); + } + return result; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 30df7e12e099cf..2edd44cbd1825d 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -35,7 +35,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 { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, readSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, 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, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionEhcliAdopted, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, 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, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } 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'; @@ -57,11 +57,11 @@ import { AgentSideEffects, type IAgentSideEffectsOptions } from './agentSideEffe import { AgentHostLocalTurns } from './agentHostLocalTurns.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, AGENT_HOST_TITLE_SOURCE_AUTO, type AgentHostTitleSource, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, 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, 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, readSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts } from '../common/sessionArtifacts.js'; +import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts } from '../common/sessionArtifacts.js'; import { AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; -import { AGENT_HOST_CATALOG_PROJECTION_VERSION, projectAgentHostCatalog, type AgentHostCatalogJsonValue, type IAgentHostCatalogSource } from './agentHostCatalogProjection.js'; +import { AGENT_HOST_CATALOG_PROJECTION_VERSION } from './agentHostCatalogProjection.js'; import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult } from './agentHostCatalogReconciliationService.js'; import { IAgentHostStorageService } from './agentHostStorageService.js'; import { AgentHostCatalogShadowValidator, type AgentHostCatalogReadMode, type IAgentHostCatalogShadowValidationReporter } from './agentHostCatalogShadowValidator.js'; @@ -93,6 +93,8 @@ import { SessionCoordinationService } from './sessionCoordination.js'; 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, toCatalogJsonValue } from './agentHostCatalogSourceResolver.js'; +import { AgentHostPeerChatStore, IPersistedPeerChat, PEER_CHATS_METADATA_KEY } from './agentHostPeerChatStore.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -203,11 +205,6 @@ const SESSION_RELEASE_GRACE_MS = (() => { return Number.isFinite(parsed) && parsed >= 0 ? parsed : 30_000; })(); -/** - * Downgrade-compatible session metadata for peer provider backing. A missing - * value triggers one-time migration; `[]` is the explicit empty sentinel. - */ -const PEER_CHATS_METADATA_KEY = 'peerChats'; const ANNOTATIONS_METADATA_KEY = 'annotations'; function isRecord(value: unknown): value is Record { @@ -296,23 +293,6 @@ 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 downgrade-compatible peer backing entry. Central catalog rows own - * membership and list metadata; `providerData` remains opaque session data. - */ -interface IPersistedPeerChat { - readonly uri: string; - readonly providerData?: string; - readonly origin?: ChatOrigin; -} - interface ICatalogChat { readonly uri: string; readonly kind: 'default' | 'peer'; @@ -320,22 +300,6 @@ interface ICatalogChat { readonly origin?: ChatOrigin; } -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?: AgentHostCatalogJsonValue; - }[]; -} - /** * 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 @@ -487,6 +451,8 @@ export class AgentService extends Disposable implements IAgentService { private readonly _orchestratorDatabase: IAgentHostDatabase; private readonly _catalogReadMode: AgentHostCatalogReadMode; private readonly _catalogSyncService: AgentHostCatalogSyncService; + private readonly _catalogSourceResolver: AgentHostCatalogSourceResolver; + private readonly _peerChatStore: AgentHostPeerChatStore; private readonly _catalogReconciliationService: AgentHostCatalogReconciliationService; private readonly _catalogShadowValidator: AgentHostCatalogShadowValidator; private readonly _catalogListReader: AgentHostCatalogListReader; @@ -532,14 +498,6 @@ export class AgentService extends Disposable implements IAgentService { private readonly _downloadProgressInterest = new Map>(); /** Subscriptions to provider progress events; cleared when providers change. */ private readonly _providerSubscriptions = this._register(new DisposableStore()); - /** - * Per-session tail of in-flight legacy peer-backing 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; @@ -690,6 +648,12 @@ export class AgentService extends Disposable implements IAgentService { this._serverToolHost = collaborators.serverToolHost; this._catalogReadMode = core.catalogReadMode ?? 'legacy'; this._catalogSyncService = new AgentHostCatalogSyncService(this._sessionDataService, this._orchestratorDatabase, this._logService); + this._catalogSourceResolver = new AgentHostCatalogSourceResolver({ + openDatabase: session => this._sessionDataService.openDatabase(session), + isUnpersistedChatBacking: session => this._unpersistedChatBackings.has(session.toString()), + worktreeProjectFromRepositoryRoot, + }); + this._peerChatStore = new AgentHostPeerChatStore(this._sessionDataService, this._logService); this._sessionsV2MigrationService = new AgentHostSessionsV2MigrationService( this._orchestratorDatabase, this._sessionDataService, @@ -1367,7 +1331,7 @@ export class AgentService extends Disposable implements IAgentService { if (central) { return central.some(candidate => candidate.kind === 'peer' && candidate.uri === chat.toString()); } - const persisted = await this._readPersistedPeerChatCatalog(session); + const persisted = await this._peerChatStore.tryRead(session); return persisted?.some(candidate => candidate.uri === chat.toString()) === true; } @@ -1614,7 +1578,7 @@ export class AgentService extends Disposable implements IAgentService { if (!summary || !state) { throw new Error(`Cannot persist list-visible state for unknown session ${sessionKey}`); } - const result = await this._catalogSyncService.synchronizeWithFactory(session, () => this._buildCatalogSyncRequest(session, { + const result = await this._catalogSyncService.synchronizeWithFactory(session, () => this._catalogSourceResolver.buildCatalogSyncRequest(session, { modifiedTime: Date.parse(summary.modifiedAt), title: summary.title, status: summary.status, @@ -1624,7 +1588,7 @@ export class AgentService extends Disposable implements IAgentService { meta: summary._meta, chats: (chatsOverride ?? this._catalogChatsFromState(state)).map(chat => ({ ...chat, - origin: this._toCatalogJsonValue(chat.origin), + origin: toCatalogJsonValue(chat.origin), })), }, metadataOverrides, false)); if (result.status === 'pending') { @@ -1647,7 +1611,7 @@ export class AgentService extends Disposable implements IAgentService { const peers = await this._readOrMigrateLegacyPeerChatCatalog(agent, registered.session); return { status: 'available', - request: await this._buildCatalogSyncRequest(registered.session, { + request: await this._catalogSourceResolver.buildCatalogSyncRequest(registered.session, { modifiedTime: metadata.modifiedTime, title: metadata.summary, status: metadata.status ?? SessionStatus.Idle, @@ -1664,7 +1628,7 @@ export class AgentService extends Disposable implements IAgentService { ...peers.map(peer => ({ uri: peer.uri, kind: 'peer' as const, - origin: this._toCatalogJsonValue(peer.origin), + origin: toCatalogJsonValue(peer.origin), })), ], }, {}, true), @@ -1682,298 +1646,6 @@ export class AgentService extends Disposable implements IAgentService { })); } - private async _buildCatalogSyncRequest(session: URI, state: ICatalogSourceState, metadataOverrides: Readonly>, preferPersistedMetadata: boolean): Promise<{ readonly source: IAgentHostCatalogSource; readonly legacyMetadata: Readonly> }> { - const metadataKeys: Record = { - [SESSION_CUSTOM_TITLE_KEY]: true, - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: true, - [AH_META_IS_READ_DB_KEY]: true, - [AH_META_IS_ARCHIVED_DB_KEY]: true, - [AH_META_IS_DONE_DB_KEY]: true, - [AH_META_ORCHESTRATION_DB_KEY]: true, - [AH_META_WORKSPACELESS_DB_KEY]: true, - [AH_META_EHCLI_ADOPTED_DB_KEY]: true, - [SESSION_META_MULTI_ROOT_KEY]: true, - [SESSION_META_FOLDER_PICKER_KEY]: true, - [SESSION_ARTIFACTS_KEY]: true, - [META_CHANGES_SUMMARY]: true, - [CHAT_BACKING_METADATA_KEY]: true, - [WORKTREE_META_REPOSITORY_ROOT]: true, - ...GIT_DB_METADATA_KEYS, - }; - for (const chat of state.chats) { - metadataKeys[customChatTitleMetadataKey(chat.uri)] = true; - metadataKeys[customChatTitleSourceMetadataKey(chat.uri)] = true; - } - - const ref = this._sessionDataService.openDatabase(session); - let persisted: { readonly [key: string]: string | undefined }; - try { - persisted = await ref.object.getMetadataObject(metadataKeys); - } finally { - ref.dispose(); - } - const metadata = { ...persisted, ...metadataOverrides }; - const title = (preferPersistedMetadata ? metadata[SESSION_CUSTOM_TITLE_KEY] : metadataOverrides[SESSION_CUSTOM_TITLE_KEY]) ?? state.title ?? ''; - const titleSource = this._catalogTitleSource(metadata[SESSION_CUSTOM_TITLE_SOURCE_KEY]); - const persistedMultiRoot = metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined - ? parseSessionMultiRootMetadata(metadata[SESSION_META_MULTI_ROOT_KEY]) - : undefined; - const multiRoot = preferPersistedMetadata - ? (metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined ? persistedMultiRoot : readSessionMultiRootMetadata(state.meta)) - : readSessionMultiRootMetadata(state.meta) ?? persistedMultiRoot; - const persistedFolderPicker = metadata[SESSION_META_FOLDER_PICKER_KEY] !== undefined - ? parseSessionFolderPickerDecision(metadata[SESSION_META_FOLDER_PICKER_KEY]) - : undefined; - const folderPicker = preferPersistedMetadata - ? (metadata[SESSION_META_FOLDER_PICKER_KEY] !== undefined ? persistedFolderPicker : readSessionFolderPickerDecision(state.meta)) - : readSessionFolderPickerDecision(state.meta) ?? persistedFolderPicker; - const persistedArtifacts = parseSessionArtifacts(metadata[SESSION_ARTIFACTS_KEY]); - 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 persistedOrchestration = metadata[AH_META_ORCHESTRATION_DB_KEY] !== undefined - ? parseSessionOrchestration(metadata[AH_META_ORCHESTRATION_DB_KEY]) - : undefined; - const orchestration = preferPersistedMetadata - ? (metadata[AH_META_ORCHESTRATION_DB_KEY] !== undefined ? persistedOrchestration : readSessionOrchestration(state.meta)) - : (metadataOverrides[AH_META_ORCHESTRATION_DB_KEY] !== undefined ? persistedOrchestration : readSessionOrchestration(state.meta) ?? persistedOrchestration); - const persistedGitHub = metadata[META_GITHUB_STATE] !== undefined - ? this._readPersistedGitHubState(metadata[META_GITHUB_STATE]) - : undefined; - const github = preferPersistedMetadata - ? (metadata[META_GITHUB_STATE] !== undefined ? persistedGitHub : readSessionGitHubState(state.meta)) - : readSessionGitHubState(state.meta) ?? persistedGitHub; - const persistedSourceControl = metadata[META_SOURCE_CONTROL_STATE] !== undefined - ? this._readPersistedSourceControlState(metadata[META_SOURCE_CONTROL_STATE]) - : undefined; - const sourceControl = preferPersistedMetadata - ? (metadata[META_SOURCE_CONTROL_STATE] !== undefined ? persistedSourceControl : readSessionSourceControlState(state.meta)) - : readSessionSourceControlState(state.meta) ?? persistedSourceControl; - const persistedGit = metadata[META_GIT_STATE] !== undefined - ? this._readPersistedGitState(metadata[META_GIT_STATE]) - : undefined; - const git = readSessionGitState(state.meta) ?? persistedGit; - const persistedWorkspaceless = metadata[AH_META_WORKSPACELESS_DB_KEY] === 'true'; - 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 - ? metadata[AH_META_IS_READ_DB_KEY] === 'true' - : stateIsRead; - const persistedArchived = metadata[AH_META_IS_ARCHIVED_DB_KEY] ?? metadata[AH_META_IS_DONE_DB_KEY]; - const isArchived = preferPersistedMetadata && persistedArchived !== undefined - ? persistedArchived === 'true' - : (state.status & SessionStatus.IsArchived) !== 0; - const persistedChanges = metadata[META_CHANGES_SUMMARY] !== undefined - ? this._readPersistedChanges(metadata[META_CHANGES_SUMMARY]) - : undefined; - const changes = preferPersistedMetadata && metadata[META_CHANGES_SUMMARY] !== undefined ? persistedChanges : state.changes; - const worktreeProject = worktreeProjectFromRepositoryRoot(metadata[WORKTREE_META_REPOSITORY_ROOT]); - const isChatBacking = !!metadata[CHAT_BACKING_METADATA_KEY] || this._unpersistedChatBackings.has(session.toString()); - const source: IAgentHostCatalogSource = { - modifiedTime: state.modifiedTime, - title: title || undefined, - titleSource, - isRead, - isArchived, - project: worktreeProject - ? { uri: worktreeProject.uri.toString(), displayName: worktreeProject.displayName } - : state.project, - workspaceless, - isChatBacking, - ehcliAdoptable: readSessionEhcliAdoptable(state.meta), - ehcliAdopted: readSessionEhcliAdopted(state.meta) || metadata[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true', - multiRoot, - folderPicker, - changes, - github, - git, - sourceControl, - artifacts, - orchestration, - workingDirectories: state.workingDirectories, - chats: state.chats.map((chat, order) => ({ - uri: chat.uri, - order, - kind: chat.kind, - title: (preferPersistedMetadata ? metadata[customChatTitleMetadataKey(chat.uri)] : metadataOverrides[customChatTitleMetadataKey(chat.uri)]) || chat.title || undefined, - titleSource: this._catalogTitleSource(metadata[customChatTitleSourceMetadataKey(chat.uri)]), - origin: chat.origin, - })), - }; - const legacyMetadata: Record = { - ...metadataOverrides, - [AH_META_IS_READ_DB_KEY]: source.isRead ? 'true' : '', - [AH_META_IS_ARCHIVED_DB_KEY]: source.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), - [AH_META_ORCHESTRATION_DB_KEY]: orchestration ? JSON.stringify(orchestration) : '', - }; - if (source.workspaceless || metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { - legacyMetadata[AH_META_WORKSPACELESS_DB_KEY] = source.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 (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 { source, legacyMetadata }; - } - - private _catalogTitleSource(value: string | undefined): AgentHostTitleSource { - return value === 'user' || value === 'agent' || value === 'auto' ? value : AGENT_HOST_TITLE_SOURCE_AUTO; - } - - private _readPersistedGitHubState(value: string | undefined): ISessionGitHubState | undefined { - if (!value) { - return undefined; - } - try { - return readSessionGitHubState({ [SESSION_META_GITHUB_KEY]: JSON.parse(value) }); - } catch { - return undefined; - } - } - - private _readPersistedSourceControlState(value: string | undefined): ISessionSourceControlState | undefined { - if (!value) { - return undefined; - } - try { - return readSessionSourceControlState({ [SESSION_META_SOURCE_CONTROL_KEY]: JSON.parse(value) }); - } catch { - return undefined; - } - } - - private _readPersistedGitState(value: string | undefined): ISessionGitState | undefined { - if (!value) { - return undefined; - } - try { - const projected = projectAgentHostCatalog({ - modifiedTime: 0, - isRead: false, - isArchived: false, - workspaceless: false, - git: JSON.parse(value), - workingDirectories: [], - chats: [], - }, { - session: 'agent-host-catalog-git-validation', - sessionGeneration: 'agent-host-catalog-git-validation', - sourceRevision: 0, - }); - return projected.ok ? projected.value.source.git : undefined; - } catch { - return undefined; - } - } - - private _readPersistedChanges(value: string | undefined): ChangesSummary | undefined { - if (!value) { - return undefined; - } - try { - return JSON.parse(value) as ChangesSummary; - } catch { - return undefined; - } - } - - private _toCatalogJsonValue(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 = this._toCatalogJsonValue(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 = this._toCatalogJsonValue(entry); - if (converted !== undefined) { - result[key] = converted; - } - } - return result; - } - return undefined; - } - - private _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; - } - private _agentMergeRestore: Promise = Promise.resolve(); private _agentMergeIndexWrites: Promise = Promise.resolve(); @@ -2434,7 +2106,7 @@ export class AgentService extends Disposable implements IAgentService { private async _buildImportedCatalogSyncRequest(provider: IAgent, metadata: IAgentSessionMetadata, external: boolean, seedExternalRead: boolean): Promise { const peers = await this._readOrMigrateLegacyPeerChatCatalog(provider, metadata.session); - return this._buildCatalogSyncRequest(metadata.session, { + return this._catalogSourceResolver.buildCatalogSyncRequest(metadata.session, { modifiedTime: metadata.modifiedTime, title: metadata.summary, status: external && seedExternalRead ? (metadata.status ?? SessionStatus.Idle) | SessionStatus.IsRead : metadata.status ?? SessionStatus.Idle, @@ -2451,7 +2123,7 @@ export class AgentService extends Disposable implements IAgentService { ...peers.map(peer => ({ uri: peer.uri, kind: 'peer' as const, - origin: this._toCatalogJsonValue(peer.origin), + origin: toCatalogJsonValue(peer.origin), })), ], }, external && seedExternalRead ? { [AH_META_IS_READ_DB_KEY]: 'true' } : {}, true); @@ -3525,7 +3197,7 @@ export class AgentService extends Disposable implements IAgentService { ]; this._catalogSyncSuppressedSessions.add(sessionKey); try { - await this._persistPeerChat(session, chat, providerData, peerChatOrigin); + await this._peerChatStore.upsert(session, chat, providerData, peerChatOrigin); await this._persistOrderedListVisibleSessionState(session, title === undefined ? {} : { [customChatTitleMetadataKey(chat.toString())]: title, [customChatTitleSourceMetadataKey(chat.toString())]: AGENT_HOST_TITLE_SOURCE_AUTO, @@ -3535,7 +3207,7 @@ export class AgentService extends Disposable implements IAgentService { this._flushDeferredCatalogMetadataOverrides(session); let catalogRollbackError: Error | undefined; try { - await this._removePersistedPeerChat(session, chat); + await this._peerChatStore.remove(session, chat); } catch (rollbackError) { catalogRollbackError = rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)); } @@ -3653,7 +3325,7 @@ export class AgentService extends Disposable implements IAgentService { if (provider) { await this._disposeChat(provider, chat); } - await this._removePersistedPeerChat(session, chat); + await this._peerChatStore.remove(session, chat); await this._clearChatDraft(session, chat); const state = this._stateManager.getSessionState(sessionKey); if (state) { @@ -6050,7 +5722,7 @@ export class AgentService extends Disposable implements IAgentService { private async _restorePeerChats(agent: IAgent, session: URI, centralChatCatalog?: readonly ICatalogChat[] | false): Promise { const central = centralChatCatalog === false ? undefined : centralChatCatalog ?? await this._readCentralChatCatalog(session); if (central) { - const persisted = await this._readPersistedPeerChatCatalog(session, true); + const persisted = await this._peerChatStore.tryRead(session, true); if (persisted === undefined) { await this._migrateLegacyPeerChats(agent, session); } else { @@ -6059,7 +5731,7 @@ export class AgentService extends Disposable implements IAgentService { await this._persistOrderedListVisibleSessionState(session, {}); return; } - const persisted = await this._readPersistedPeerChatCatalog(session); + const persisted = await this._peerChatStore.tryRead(session); if (persisted !== undefined) { await this._restorePeerChatsFromCatalog(session, persisted); await this._persistOrderedListVisibleSessionState(session, {}); @@ -6087,7 +5759,7 @@ export class AgentService extends Disposable implements IAgentService { uri: chat.uri, kind: chat.kind, title: chat.title, - origin: this._fromCatalogChatOrigin(chat.origin), + origin: fromCatalogChatOrigin(chat.origin), })); } @@ -6103,7 +5775,7 @@ export class AgentService extends Disposable implements IAgentService { } private async _readOrMigrateLegacyPeerChatCatalog(agent: IAgent, session: URI): Promise { - const persisted = await this._readPersistedPeerChatCatalog(session); + const persisted = await this._peerChatStore.tryRead(session); if (persisted !== undefined) { return persisted; } @@ -6112,7 +5784,7 @@ export class AgentService extends Disposable implements IAgentService { uri: chat.uri.toString(), ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}), })); - await this._enqueuePeerChatCatalogWrite(session, () => [...entries]); + await this._peerChatStore.replace(session, entries); return entries; } @@ -6172,7 +5844,7 @@ export class AgentService extends Disposable implements IAgentService { } try { const [persisted, draft] = await Promise.all([ - providerData === undefined ? this._readPersistedPeerChatBacking(session, chat) : undefined, + providerData === undefined ? this._peerChatStore.find(session, chat) : undefined, this._getChatDraft(session, chat), ]); const effectiveProviderData = providerData ?? persisted?.providerData; @@ -6188,11 +5860,6 @@ export class AgentService extends Disposable implements IAgentService { } } - private async _readPersistedPeerChatBacking(session: URI, chat: URI): Promise { - const entries = await this._readPersistedPeerChatCatalog(session); - return entries?.find(entry => entry.uri === chat.toString()); - } - /** * Re-persists a peer chat's opaque `providerData` blob when the agent * reports it changed (e.g. per-chat model switch or fork remap). @@ -6213,7 +5880,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()}`)); } @@ -6368,80 +6035,6 @@ export class AgentService extends Disposable implements IAgentService { } } - /** - * Reads downgrade-compatible peer backing metadata. Missing returns - * `undefined`, `[]` is the explicit empty sentinel, and malformed data is - * treated as absent so migration can rebuild it. - */ - private async _readPersistedPeerChatCatalog(session: URI, batched = false): Promise { - const ref = 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 this._parsePersistedPeerChatCatalog(session, raw); - } catch (err) { - this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); - return undefined; - } finally { - ref.dispose(); - } - } - - private _parsePersistedPeerChatCatalog(session: URI, raw: string): IPersistedPeerChat[] { - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) { - throw new Error('expected an array'); - } - const sessionKey = session.toString(); - const seen = new Set(); - const result: IPersistedPeerChat[] = []; - for (let index = 0; index < parsed.length; 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; - } - const originValue = this._toCatalogJsonValue(value.origin); - const origin = this._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 } : {}), - }); - } - return result; - } - /** * Marks a chat's backing SDK session so legacy discovery cannot register * it as a standalone top-level session. Best-effort and never throws: @@ -6480,81 +6073,6 @@ export class AgentService extends Disposable implements IAgentService { } } - /** - * Inserts or updates a peer's downgrade-compatible backing metadata, - * 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): Promise { - const chatUri = chat.toString(); - return this._enqueuePeerChatCatalogWrite(session, entries => { - const existing = entries.find(entry => entry.uri === chatUri); - const effectiveOrigin = origin ?? existing?.origin; - const next = entries.filter(entry => entry.uri !== chatUri); - next.push({ - uri: chatUri, - ...(providerData !== undefined ? { providerData } : {}), - ...(effectiveOrigin !== undefined ? { origin: effectiveOrigin } : {}), - }); - return next; - }); - } - - /** - * Removes a peer chat from downgrade-compatible backing metadata. - */ - private _removePersistedPeerChat(session: URI, chat: URI): Promise { - const chatUri = chat.toString(); - return this._enqueuePeerChatCatalogWrite(session, entries => entries.filter(entry => entry.uri !== chatUri)); - } - - /** - * 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 _applyPeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { - const ref = this._sessionDataService.openDatabase(session); - try { - let current: IPersistedPeerChat[] = []; - try { - const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); - if (raw !== undefined) { - current = this._parsePersistedPeerChatCatalog(session, raw); - } - } catch (err) { - this._logService.warn(`[AgentService] Replacing malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); - } - const updated = this._parsePersistedPeerChatCatalog(session, JSON.stringify(mutate(current))); - await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated)); - } finally { - ref.dispose(); - } - } - /** Reads a chat's persisted custom title (default or peer chat), if any. */ private async _readPersistedChatTitle(session: URI, chatUri: URI): Promise { const ref = await this._sessionDataService.tryOpenDatabase?.(session); 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..f2d3266d245963 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { SessionArtifactType, withSessionArtifacts } from '../../common/sessionArtifacts.js'; +import { ChatOriginKind } from '../../common/state/protocol/state.js'; +import { AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionSourceControlOutcome, SessionStatus, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, withSessionSourceControlState, withSessionWorkspaceless } from '../../common/state/sessionState.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', link: 'https://example.com/live' }; +const persistedArtifact = { id: 'persisted-artifact', type: SessionArtifactType.Issue, label: 'Persisted artifact', link: 'https://example.com/persisted' }; +const liveOrchestration = { parentSession: 'agenthost:live-parent', creatorSession: 'agenthost:live-creator', label: 'live', coordinateWithCreator: true } as const; +const persistedOrchestration = { parentSession: 'agenthost:persisted-parent', creatorSession: 'agenthost:persisted-creator', label: 'persisted', coordinateWithCreator: false } 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 = withSessionOrchestration(meta, liveOrchestration); + 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_ORCHESTRATION_DB_KEY]: JSON.stringify(persistedOrchestration), + [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): AgentHostCatalogSourceResolver { + return new AgentHostCatalogSourceResolver({ + openDatabase: () => ({ + 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 }, + }, + dispose: () => { }, + }), + isUnpersistedChatBacking: () => unpersistedBacking, + worktreeProjectFromRepositoryRoot: root => root ? { uri: URI.parse(root), displayName: 'Persisted worktree' } : undefined, + }); +} + +suite('AgentHostCatalogSourceResolver', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + 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, { + source: { + modifiedTime: 123, + title: 'Override title', + titleSource: 'user', + isRead: false, + isArchived: false, + project: { uri: 'file:///persisted-worktree', displayName: 'Persisted worktree' }, + workspaceless: true, + isChatBacking: true, + ehcliAdoptable: true, + ehcliAdopted: true, + multiRoot: { workspaceFile: 'file:///live.code-workspace' }, + folderPicker: { hidden: false }, + changes: { additions: 1, deletions: 2, files: 3 }, + github: liveGitHub, + git: liveGit, + sourceControl: liveSourceControl, + artifacts: [liveArtifact], + orchestration: liveOrchestration, + workingDirectories: ['file:///live'], + chats: [{ + uri: chat, + order: 0, + kind: 'default', + title: '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_ORCHESTRATION_DB_KEY]: JSON.stringify({ + parentSession: liveOrchestration.parentSession, + creatorSession: liveOrchestration.creatorSession, + coordinateWithCreator: liveOrchestration.coordinateWithCreator, + label: liveOrchestration.label, + }), + [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, { + source: { + modifiedTime: 123, + title: 'Persisted title', + titleSource: 'user', + isRead: true, + isArchived: true, + project: { uri: 'file:///persisted-worktree', displayName: 'Persisted worktree' }, + workspaceless: false, + isChatBacking: true, + ehcliAdoptable: true, + ehcliAdopted: true, + multiRoot: { workspaceFile: 'file:///persisted.code-workspace' }, + folderPicker: { hidden: true, primary: 'file:///persisted' }, + changes: { additions: 10, deletions: 20, files: 30 }, + github: persistedGitHub, + git: liveGit, + sourceControl: { merge: undefined, ...persistedSourceControl }, + artifacts: [persistedArtifact], + orchestration: persistedOrchestration, + workingDirectories: ['file:///live'], + chats: [{ + uri: chat, + order: 0, + kind: 'default', + title: '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_ORCHESTRATION_DB_KEY]: JSON.stringify({ + parentSession: persistedOrchestration.parentSession, + creatorSession: persistedOrchestration.creatorSession, + coordinateWithCreator: persistedOrchestration.coordinateWithCreator, + label: persistedOrchestration.label, + }), + [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 }), + }, + }); + }); +}); 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..fbe88b045ebf48 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AgentHostPeerChatStore, 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; + +suite('AgentHostPeerChatStore', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function createStore(database: TestSessionDatabase): AgentHostPeerChatStore { + return new AgentHostPeerChatStore(createSessionDataService(database), new NullLogService()); + } + + 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.tryRead(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.tryRead(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('refreshes provider data without dropping a persisted origin', async () => { + const database = new TestSessionDatabase(); + const store = createStore(database); + await store.upsert(session, first, 'old', origin); + + await store.upsert(session, first, 'refreshed'); + + assert.deepStrictEqual(await store.tryRead(session), [ + { uri: first.toString(), providerData: 'refreshed', origin }, + ]); + }); + + 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: '[]', + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 22670dd9f20eac..b12845f90c19b6 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9252,7 +9252,9 @@ suite('AgentService (node dispatcher)', () => { const db = new TestSessionDatabase(); const catalogDatabase = new TransientRegistryWriteDatabase(); const session = AgentSession.uri('copilot', 'projection-v2'); - await createAgentSession(copilotAgent, { session }); + const agent = disposables.add(new MockAgent('copilot')); + agent.sessionMetadataOverrides = { modifiedTime: 2 }; + await createAgentSession(agent, { session }); await catalogDatabase.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); const oldSnapshot: ISessionCatalogSyncPendingSnapshot = { sessionGeneration: 'test-generation', @@ -9273,10 +9275,10 @@ suite('AgentService (node dispatcher)', () => { })._catalogReconciliationService; reconciliation.schedule = () => { }; - localService.registerProvider(copilotAgent); + localService.registerProvider(agent); await (localService as unknown as { _awaitInitialProviderMigrationForProvider(provider: IAgent): Promise; - })._awaitInitialProviderMigrationForProvider(copilotAgent); + })._awaitInitialProviderMigrationForProvider(agent); const imported = await catalogDatabase.getSessionV2(session.toString()); const writesAfterImport = catalogDatabase.sessionV2UpsertAttempts; await reconciliation.runPass(); From b1f2a96a030ae8d79cf52f1853ce925aa9ed7b4c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 26 Aug 2026 10:59:20 +0200 Subject: [PATCH 04/30] agentHost: simplify session catalog persistence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 9 + scripts/test-agent-host-live-compat.ts | 816 ++++++++++++ .../node/agentHostCatalogListReader.ts | 123 +- .../node/agentHostCatalogProjection.ts | 1148 ++++++----------- .../agentHostCatalogReconciliationService.ts | 85 +- .../node/agentHostCatalogShadowValidator.ts | 350 ----- .../node/agentHostCatalogSourceResolver.ts | 64 +- .../node/agentHostCatalogSyncService.ts | 135 +- .../agentHost/node/agentHostDatabase.ts | 352 +++-- .../agentHostSessionsV2MigrationService.ts | 27 +- .../platform/agentHost/node/agentService.ts | 103 +- .../agentHost/node/agentServiceComposition.ts | 3 - .../node/agentHostCatalogListReader.test.ts | 157 ++- .../node/agentHostCatalogProjection.test.ts | 555 +++----- ...ntHostCatalogReconciliationService.test.ts | 84 +- .../agentHostCatalogShadowValidator.test.ts | 385 ------ .../agentHostCatalogSourceResolver.test.ts | 59 +- .../node/agentHostCatalogSyncService.test.ts | 194 +-- .../test/node/agentHostDatabase.test.ts | 286 ++-- .../agentHost/test/node/agentService.test.ts | 676 +++++----- .../test/node/agentServiceTestUtils.ts | 7 - .../test/node/agentSessionRegistry.test.ts | 5 +- .../test/node/agentSideEffects.test.ts | 8 +- .../agentHost/test/node/e2e/README.md | 189 +++ .../node/e2e/harness/agentHostBuildPlan.ts | 197 +++ .../e2e/harness/agentHostLiveCompatBuilds.ts | 88 ++ .../agentHostLiveCompatHarness.test.ts | 162 +++ .../harness/crossVersionAgentHostTarget.ts | 190 +++ .../agentHostLiveCompatCapabilities.test.ts | 76 ++ .../agentHostLiveCompatCapabilities.ts | 115 ++ .../liveCompat/agentHostLiveCompatClient.ts | 144 +++ .../liveCompat/agentHostLiveCompatMatrix.ts | 130 ++ .../liveCompat/agentHostLiveCompatProtocol.ts | 64 + .../liveCompat/agentHostLiveCompatServer.ts | 151 +++ .../backwardCompatibilityMatrix.test.ts | 81 ++ .../liveCompat/backwardCompatibilityMatrix.ts | 627 +++++++++ .../liveCompat/forwardMigrationMatrix.test.ts | 63 + .../e2e/liveCompat/forwardMigrationMatrix.ts | 624 +++++++++ .../e2e/liveCompat/liveCompatRunner.test.ts | 186 +++ .../e2e/liveCompat/recoveryMatrix.test.ts | 161 +++ .../node/e2e/liveCompat/recoveryMatrix.ts | 828 ++++++++++++ .../runBackwardCompatibilityMatrix.ts | 87 ++ .../liveCompat/runForwardMigrationMatrix.ts | 128 ++ .../node/e2e/liveCompat/runRecoveryMatrix.ts | 177 +++ .../liveCompat/sameBuildRestartBaseline.ts | 403 ++++++ .../test/node/serverIntegrationTestHelpers.ts | 7 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 45 +- 47 files changed, 7393 insertions(+), 3161 deletions(-) create mode 100644 scripts/test-agent-host-live-compat.ts delete mode 100644 src/vs/platform/agentHost/node/agentHostCatalogShadowValidator.ts delete mode 100644 src/vs/platform/agentHost/test/node/agentHostCatalogShadowValidator.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatBuilds.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatHarness.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/harness/crossVersionAgentHostTarget.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatClient.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatMatrix.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatProtocol.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatServer.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/liveCompatRunner.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/runBackwardCompatibilityMatrix.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/runForwardMigrationMatrix.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/runRecoveryMatrix.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/sameBuildRestartBaseline.ts diff --git a/package.json b/package.json index 3c518289b1eb2b..d8d6039891f595 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,15 @@ "test-agent-host-e2e": "node scripts/test-agent-host-e2e.ts", "markdown-editor-package-json-check": "npm --prefix extensions/markdown-language-features run check-markdown-editor-package-json", "test-agent-host-e2e-coverage": "node scripts/agent-host-e2e-coverage.ts", + "agent-host-live-compat": "node scripts/test-agent-host-live-compat.ts", + "agent-host-live-compat-prepare": "node scripts/test-agent-host-live-compat.ts --prepare-all", + "agent-host-live-compat-check": "node scripts/test-agent-host-live-compat.ts --check", + "agent-host-live-compat-baselines": "node scripts/test-agent-host-live-compat.ts --run-baselines", + "agent-host-live-compat-forward": "node scripts/test-agent-host-live-compat.ts --run-forward", + "agent-host-live-compat-backward": "node scripts/test-agent-host-live-compat.ts --run-backward", + "agent-host-live-compat-recovery": "node scripts/test-agent-host-live-compat.ts --run-recovery", + "agent-host-live-compat-all": "node scripts/test-agent-host-live-compat.ts --run-all", + "agent-host-live-compat-pr": "node scripts/test-agent-host-live-compat.ts --run-all --pr", "check-cyclic-dependencies": "node build/lib/checkCyclicDependencies.ts out", "preinstall": "node build/npm/preinstall.ts", "postinstall": "node build/npm/postinstall.ts", diff --git a/scripts/test-agent-host-live-compat.ts b/scripts/test-agent-host-live-compat.ts new file mode 100644 index 00000000000000..a8f09660f13dae --- /dev/null +++ b/scripts/test-agent-host-live-compat.ts @@ -0,0 +1,816 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Prepare the Agent Host builds that live-compatibility E2E scenarios run + * against. + * + * Historical checkpoints are materialized into **detached git worktrees** under + * a cache root outside the repository and compiled there. The repository the + * developer is working in is never checked out, reset, stashed or cleaned: the + * `current` checkpoint is simply built in place. + * + * Once builds are prepared it also *runs* the live-compat scenarios against + * them, since the two steps share the same checkpoint list and cache layout. + * Preparation and execution stay separate commands: preparing compiles whole + * source trees and is slow, while a scenario run is cheap and repeated. + * + * Usage: + * node scripts/test-agent-host-live-compat.ts --list + * node scripts/test-agent-host-live-compat.ts --prepare legacy [--prepare current] + * node scripts/test-agent-host-live-compat.ts --prepare-all [--force] + * node scripts/test-agent-host-live-compat.ts --check + * node scripts/test-agent-host-live-compat.ts --run-baselines [--build legacy] + * node scripts/test-agent-host-live-compat.ts --run-forward + * node scripts/test-agent-host-live-compat.ts --run-backward + * node scripts/test-agent-host-live-compat.ts --run-recovery + * node scripts/test-agent-host-live-compat.ts --run-all [--pr] [--output-dir ] + * + * Every `--run-*` command writes a stable JSON summary under + * `.build/agent-host-live-compat` (override with `--output-dir`) and exits + * nonzero if any scenario failed, including scenarios that failed only because + * their checkpoint was not prepared: an unresolved checkpoint is reported as a + * failure, never skipped. + * + * The cache layout and marker format are the contract shared with + * `src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts`; + * change both together. + */ + +const childProcess: typeof import('child_process') = require('child_process'); +const fs: typeof import('fs') = require('fs'); +const os: typeof import('os') = require('os'); +const path: typeof import('path') = require('path'); +const { spawnSync } = childProcess; +const { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } = fs; +const { tmpdir } = os; +const { dirname, join, resolve } = path; +const { pathToFileURL }: typeof import('url') = require('url'); + +const repoRoot = resolve(__dirname, '..'); + +/** Keep in sync with `AGENT_HOST_LIVE_COMPAT_RECIPE_VERSION`. */ +const RECIPE_VERSION = '1'; +/** + * Legacy marker location: inside the worktree, per `IAgentHostBuildPlan`. + * + * Still read so an already-prepared cache keeps working, but no longer written + * — see {@link markerPathFor} for why an in-worktree marker is a problem. + */ +const CACHE_MARKER_NAME = '.agent-host-live-compat-build.json'; +/** + * Files this script may itself leave in a worktree, and which therefore must + * not count as "local modifications" when deciding whether reuse is safe. + */ +const SCRIPT_OWNED_WORKTREE_FILES: readonly string[] = [CACHE_MARKER_NAME]; +/** Keep in sync with `AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH`. */ +const SERVER_ENTRY_RELATIVE_PATH = join('out', 'vs', 'platform', 'agentHost', 'node', 'agentHostServerMain.js'); + +interface IBuild { + readonly id: string; + readonly ref?: string; + readonly description: string; +} + +/** Keep in sync with `agentHostLiveCompatBuilds`. */ +const builds: readonly IBuild[] = [ + { id: 'legacy', ref: '97ed7b57c6d9becb4fe386c59157eda016050d6a', description: 'Oldest supported Agent Host build in the compatibility matrix.' }, + { id: 'predecessor', ref: '49f24d87cd32d2a696e469d2c61fb8d0cada4cc9', description: 'The build immediately preceding the in-flight changes.' }, + { id: 'intermediate', ref: '7453d67fdcde27faba527d69a535ddd51b8d1afa', description: 'Intermediate build used to exercise multi-hop upgrades.' }, + { id: 'current', description: 'The current working tree, built in place; never checked out or reset.' }, +]; + +function cacheRoot(): string { + return process.env['AGENT_HOST_LIVE_COMPAT_CACHE'] || join(tmpdir(), 'vscode-agent-host-live-compat'); +} + +function sourceRootFor(build: IBuild): string { + return build.ref === undefined ? repoRoot : join(cacheRoot(), 'builds', build.id); +} + +/** + * The matrices, in the order `--run-all` executes them. + * + * Order is cheapest-first and dependency-shaped: baselines prove each build can + * restart against its own profile at all, so a failure there explains every + * later cross-build failure and is worth seeing before spending time on them. + * + * They run **sequentially**, never in parallel. Each scenario forks real Agent + * Host processes from separately compiled trees that share this machine's temp + * space, ports and Electron caches; overlapping them would make a failure + * attributable to contention rather than to compatibility. + */ +const MATRICES = ['baselines', 'forward', 'backward', 'recovery'] as const; +type MatrixId = typeof MATRICES[number]; + +/** Default location for retained JSON evidence, relative to the repo root. */ +const DEFAULT_OUTPUT_DIR = join('.build', 'agent-host-live-compat'); + +/** Stable, per-matrix summary file names; CI collects these by name. */ +const SUMMARY_FILE_NAMES: Readonly> = { + baselines: 'baselines.json', + forward: 'forward-migration.json', + backward: 'backward-compatibility.json', + recovery: 'recovery.json', +}; + +interface IOptions { + readonly prepare: readonly string[]; + readonly force: boolean; + readonly list: boolean; + readonly check: boolean; + readonly matrices: readonly MatrixId[]; + readonly runBuilds: readonly string[] | undefined; + readonly jsonPath: string | undefined; + readonly outputDir: string; + readonly pr: boolean; +} + +async function main(): Promise { + const options = parseArguments(process.argv.slice(2)); + if (options.list) { + printStatus(); + return; + } + if (options.check) { + // Non-destructive by contract: it reports readiness and never prepares, + // compiles, checks out or deletes anything. + const missing = builds.filter(build => !isReady(build).ready); + printStatus(); + if (missing.length > 0) { + console.error(`\nNot ready: ${missing.map(build => build.id).join(', ')}. Run with --prepare-all.`); + process.exitCode = 1; + } + return; + } + for (const id of options.prepare) { + prepare(lookup(id), options.force); + } + if (options.matrices.length > 0) { + await runMatrices(options); + return; + } + printStatus(); +} + +function parseArguments(args: readonly string[]): IOptions { + const prepare: string[] = []; + const runBuilds: string[] = []; + const matrices: MatrixId[] = []; + let force = false; + let list = false; + let check = false; + let jsonPath: string | undefined; + let outputDir: string | undefined; + let pr = false; + const addMatrix = (id: MatrixId) => { + if (!matrices.includes(id)) { + matrices.push(id); + } + }; + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + if (argument === '--prepare') { + const value = args[++index]; + if (!value) { + throw new Error('--prepare requires a build id'); + } + prepare.push(value); + } else if (argument.startsWith('--prepare=')) { + prepare.push(argument.slice('--prepare='.length)); + } else if (argument === '--prepare-all') { + prepare.push(...builds.map(build => build.id)); + } else if (argument === '--force') { + force = true; + } else if (argument === '--list') { + list = true; + } else if (argument === '--check') { + check = true; + } else if (argument === '--run-baselines') { + addMatrix('baselines'); + } else if (argument === '--run-forward') { + addMatrix('forward'); + } else if (argument === '--run-backward') { + addMatrix('backward'); + } else if (argument === '--run-recovery') { + addMatrix('recovery'); + } else if (argument === '--run-all') { + for (const id of MATRICES) { + addMatrix(id); + } + } else if (argument === '--pr') { + pr = true; + } else if (argument === '--build') { + const value = args[++index]; + if (!value) { + throw new Error('--build requires a build id'); + } + runBuilds.push(value); + } else if (argument.startsWith('--build=')) { + runBuilds.push(argument.slice('--build='.length)); + } else if (argument === '--json') { + const value = args[++index]; + if (!value) { + throw new Error('--json requires a path'); + } + jsonPath = value; + } else if (argument.startsWith('--json=')) { + jsonPath = argument.slice('--json='.length); + } else if (argument === '--output-dir') { + const value = args[++index]; + if (!value) { + throw new Error('--output-dir requires a path'); + } + outputDir = value; + } else if (argument.startsWith('--output-dir=')) { + outputDir = argument.slice('--output-dir='.length); + } else { + throw new Error(`Unknown argument '${argument}'`); + } + } + if (runBuilds.length > 0 && !matrices.includes('baselines')) { + throw new Error('--build only applies to --run-baselines'); + } + if (jsonPath !== undefined && matrices.length !== 1) { + throw new Error('--json applies to a single matrix; use --output-dir to place several summaries'); + } + if (pr && matrices.length === 0) { + throw new Error('--pr selects a faster subset of a run; combine it with --run-all or a --run-* command'); + } + if (prepare.length === 0 && !list && !check && matrices.length === 0) { + list = true; + } + return { + prepare, + force, + list, + check, + matrices, + runBuilds: runBuilds.length > 0 ? runBuilds : undefined, + jsonPath, + outputDir: outputDir ?? DEFAULT_OUTPUT_DIR, + pr, + }; +} + + +function lookup(id: string): IBuild { + const build = builds.find(candidate => candidate.id === id); + if (!build) { + throw new Error(`Unknown build '${id}'; known: ${builds.map(candidate => candidate.id).join(', ')}`); + } + return build; +} + +function isReady(build: IBuild): { ready: boolean; reason?: string } { + const sourceRoot = sourceRootFor(build); + if (!existsSync(join(sourceRoot, SERVER_ENTRY_RELATIVE_PATH))) { + return { ready: false, reason: 'not compiled' }; + } + if (build.ref === undefined) { + // The working tree is never cached: it changes under us by design. + return { ready: true }; + } + const cacheKey = tryCacheKeyFor(build); + if (cacheKey === undefined) { + return { ready: false, reason: unresolvedRefReason(build.ref) }; + } + if (readMarker(build)?.cacheKey !== cacheKey) { + return { ready: false, reason: 'stale build output' }; + } + return { ready: true }; +} + +/** + * Where this script records a build's cache key, outside the worktree. + * + * The marker is *also* written inside the worktree, because that in-worktree + * path is the contract `IAgentHostBuildPlan.cacheMarkerPath` reads when the + * matrices decide whether a build is launchable — see {@link writeMarker}. + * This copy exists so the CLI's own readiness check does not depend on a file + * living in a tree it may have to re-checkout. + */ +function markerPathFor(build: IBuild): string { + return join(cacheRoot(), 'markers', `${build.id}.json`); +} + +function readMarker(build: IBuild): { cacheKey?: string } | undefined { + // The legacy in-worktree location is still read so an already-prepared + // cache is not silently invalidated by this change; it is never written. + for (const candidate of [markerPathFor(build), join(sourceRootFor(build), CACHE_MARKER_NAME)]) { + try { + return JSON.parse(readFileSync(candidate, 'utf8')) as { cacheKey?: string }; + } catch { + // Try the next location. + } + } + return undefined; +} + +/** + * Record a completed build in both places that need to know about it. + * + * The in-worktree copy is not optional: `IAgentHostBuildPlan.cacheMarkerPath` + * points there, and it is what the matrices consult to decide a build is + * launchable rather than stale. Writing only the external copy makes every + * historical build report as "stale build output" at run time — which is + * exactly what a cold end-to-end run caught. + * + * It is an untracked file, so it would ordinarily make the worktree look dirty + * and block reuse for another checkpoint. That is handled by excluding this one + * known name in {@link SCRIPT_OWNED_WORKTREE_FILES}, rather than by loosening + * the dirty check, so a genuine local edit still stops reuse. + */ +function writeMarker(build: IBuild, cacheKey: string): void { + const contents = `${JSON.stringify({ cacheKey, builtAt: new Date().toISOString() }, undefined, '\t')}\n`; + const markerPath = markerPathFor(build); + mkdirSync(dirname(markerPath), { recursive: true }); + writeFileSync(markerPath, contents); + writeFileSync(join(sourceRootFor(build), CACHE_MARKER_NAME), contents); +} + +function tryCacheKeyFor(build: IBuild): string | undefined { + const commit = tryResolveCommit(build.ref!); + return commit === undefined ? undefined : `commit:${commit}|recipe:${RECIPE_VERSION}`; +} + +/** + * Resolve a checkpoint ref to a commit, or `undefined` when it is not present. + * + * Non-throwing by design. A checkpoint can legitimately be absent — a shallow + * clone, a fork, or a checkpoint that only ever existed on a feature branch — + * and in every one of those cases the useful outcome is the runner's own + * "not ready, here is what to do" result, not a raw `git rev-parse` stack from + * deep inside a status listing. + */ +function tryResolveCommit(ref: string): string | undefined { + const result = spawnSync('git', ['rev-parse', `${ref}^{commit}`], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const commit = (result.stdout ?? '').trim(); + return /^[0-9a-f]{40}$/.test(commit) ? commit : undefined; +} + +/** Resolve a ref for a command that genuinely cannot proceed without it. */ +function resolveCommit(ref: string): string { + const commit = tryResolveCommit(ref); + if (commit === undefined) { + throw new Error(unresolvedRefMessage(ref)); + } + return commit; +} + +function unresolvedRefReason(ref: string): string { + return `checkpoint ${ref.slice(0, 10)} is not present in this repository`; +} + +/** + * Explain an absent checkpoint, including the case this suite actually hits. + * + * Some checkpoints are pinned to commits that live only on a feature branch. + * Those are unreachable from a shallow clone, from a fork, and from the default + * branch until that work lands — so "fetch it" is only half the advice, and + * re-pinning is the other half. + */ +function unresolvedRefMessage(ref: string): string { + return [ + `[agent-host-live-compat] checkpoint '${ref}' could not be resolved in ${repoRoot}.`, + ` Fetch it: git fetch origin ${ref}`, + ' If it was never on a shared branch (a feature-branch-only checkpoint), re-pin it', + ' to a commit reachable from the default branch in agentHostLiveCompatBuilds.ts.', + ].join('\n'); +} + +function prepare(build: IBuild, force: boolean): void { + const sourceRoot = sourceRootFor(build); + const state = isReady(build); + if (state.ready && !force && build.ref !== undefined) { + console.log(`[live-compat] ${build.id}: up to date (${sourceRoot})`); + return; + } + + if (build.ref === undefined) { + // The working tree is never *cached* — it changes under us by design — + // but it can still be already built, and recompiling it is the single + // slowest thing this command does. `--force` remains the way to insist. + if (state.ready && !force) { + console.log(`[live-compat] ${build.id}: already compiled (${repoRoot}); pass --force to rebuild`); + return; + } + console.log(`[live-compat] ${build.id}: building the current working tree in place (${repoRoot})`); + compile(repoRoot); + console.log(`[live-compat] ${build.id}: ready`); + return; + } + + const commit = resolveCommit(build.ref); + materializeWorktree(build, commit, sourceRoot); + installDependencies(build, sourceRoot); + compile(sourceRoot); + writeMarker(build, `commit:${commit}|recipe:${RECIPE_VERSION}`); + console.log(`[live-compat] ${build.id}: ready at ${sourceRoot} (${commit})`); +} + +function materializeWorktree(build: IBuild, commit: string, sourceRoot: string): void { + mkdirSync(join(cacheRoot(), 'builds'), { recursive: true }); + if (existsSync(join(sourceRoot, '.git'))) { + // A cached worktree restored onto a fresh machine (as CI does) carries a + // `.git` file pointing at administrative data that lives in the *main* + // repository and was never part of the archive. Detect that here rather + // than letting the first git command fail with a link-resolution error + // that reads like a corrupt checkout. + const detached = isDetachedWorktree(sourceRoot); + if (!detached) { + console.log(`[live-compat] ${build.id}: cached worktree at ${sourceRoot} is no longer linked to this repository; re-registering it`); + rmSync(sourceRoot, { recursive: true, force: true }); + run('git', ['worktree', 'prune'], repoRoot); + } else { + const head = (run('git', ['rev-parse', 'HEAD'], sourceRoot, { capture: true }) ?? '').trim(); + if (head === commit) { + return; + } + // Reuse the worktree for a different checkpoint only when it is clean: + // a dirty cached worktree may hold work someone put there on purpose. + // Files this script owns are not "someone's work": the legacy + // in-worktree marker and the install sentinel are excluded by name, + // and nothing else is, so a real edit still stops the reuse. + const status = run('git', ['status', '--porcelain'], sourceRoot, { capture: true }) ?? ''; + const foreign = status.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .filter(line => !SCRIPT_OWNED_WORKTREE_FILES.some(name => line.endsWith(name))); + if (foreign.length > 0) { + throw new Error(`Cached worktree ${sourceRoot} has local modifications; inspect and remove it manually (git worktree remove ${sourceRoot}).`); + } + run('git', ['checkout', '--detach', commit], sourceRoot); + return; + } + } + if (existsSync(sourceRoot)) { + throw new Error(`${sourceRoot} exists but is not a git worktree; remove it manually before preparing '${build.id}'.`); + } + console.log(`[live-compat] ${build.id}: creating worktree at ${sourceRoot} (${commit})`); + run('git', ['worktree', 'add', '--detach', sourceRoot, commit], repoRoot); +} + +/** True when `sourceRoot` is a git worktree this repository can still drive. */ +function isDetachedWorktree(sourceRoot: string): boolean { + const result = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: sourceRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + return result.status === 0; +} + +/** + * Native modules the Agent Host loads at run time. + * + * `--ignore-scripts` skips the install hooks that compile these, which is most + * of the saving — but the host does not merely reference them, it fails to + * start without them. A cold end-to-end run caught exactly that: every + * historical build exited 1 on `Cannot find module '../build/Debug/vscode_fs.node'`. + * So they are rebuilt explicitly, which is bounded work (~19 s) rather than the + * repository-wide postinstall. + * + * `sqlite3` backs the session database — the very thing these scenarios migrate + * — and `fs-copyfile` is reached during startup, so neither is optional. + */ +const AGENT_HOST_NATIVE_MODULES: readonly string[] = [ + '@vscode/fs-copyfile', + '@vscode/sqlite3', + '@vscode/spdlog', + '@parcel/watcher', + 'node-pty', +]; + +/** + * Install only what a checkpoint needs to transpile and run an Agent Host. + * + * A plain `npm install` here is enormously more than that. It runs the + * repository-wide `postinstall`, which installs every built-in extension and + * the remote tree: measured on a checkpoint, that is **7.6 GB** and several + * minutes, of which the Agent Host uses none. `--ignore-scripts` skips exactly + * that step, leaving the root and `build/` dependency sets — which is what + * `transpile-client` and the compiled server actually load — at **2.9 GB**. + * + * The sentinel is the other half. `node_modules` exists from the moment npm + * starts writing into it, so treating its mere presence as "installed" silently + * reuses a half-installed tree left by an interrupted or failed run, and the + * failure resurfaces later as a confusing missing-module error during compile. + * The sentinel is written only after every install has exited zero. + */ +function installDependencies(build: IBuild, sourceRoot: string): void { + const sentinel = join(cacheRoot(), 'markers', `${build.id}.install.json`); + if (existsSync(sentinel)) { + return; + } + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const args = ['install', '--ignore-scripts', '--no-audit', '--no-fund']; + console.log(`[live-compat] ${build.id}: installing dependencies in ${sourceRoot}`); + run(npm, args, sourceRoot); + // `transpile-client` runs out of `build/`, whose dependencies the root + // install does not provide and whose postinstall step we just skipped. + console.log(`[live-compat] ${build.id}: installing build dependencies`); + run(npm, args, join(sourceRoot, 'build')); + console.log(`[live-compat] ${build.id}: rebuilding native modules`); + run(npm, ['rebuild', ...AGENT_HOST_NATIVE_MODULES], sourceRoot); + mkdirSync(dirname(sentinel), { recursive: true }); + writeFileSync(sentinel, `${JSON.stringify({ installedAt: new Date().toISOString(), args, nativeModules: AGENT_HOST_NATIVE_MODULES }, undefined, '\t')}\n`); +} + +function compile(sourceRoot: string): void { + console.log(`[live-compat] compiling ${sourceRoot}`); + run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'transpile-client'], sourceRoot); + const entry = join(sourceRoot, SERVER_ENTRY_RELATIVE_PATH); + if (!existsSync(entry)) { + throw new Error(`Compilation completed but ${entry} is missing; the build recipe may not apply to this checkpoint.`); + } +} + +function run(command: string, args: readonly string[], cwd: string, options?: { capture?: boolean }): string | undefined { + const result = spawnSync(command, args, { + cwd, + env: process.env, + encoding: 'utf8', + stdio: options?.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const reason = result.signal ? `signal ${result.signal}` : `code ${result.status}`; + const details = options?.capture ? `\n${(result.stderr ?? '').trim()}` : ''; + throw new Error(`${command} ${args.join(' ')} (in ${cwd}) exited with ${reason}${details}`); + } + return options?.capture ? result.stdout : undefined; +} + +/** Compiled location of the scenario runners, produced by `npm run transpile-client`. */ +const OUT_LIVE_COMPAT_DIR = join('out', 'vs', 'platform', 'agentHost', 'test', 'node', 'e2e', 'liveCompat'); + +interface IStepResult { + readonly name: string; + readonly outcome: string; + readonly durationMs: number; + readonly detail?: string; +} + +/** + * The shape every matrix summary shares. + * + * Deliberately structural rather than a union of the four concrete summary + * types: this script only ever needs the aggregate outcome and a per-entry + * label, and the matrices remain free to add fields (recovery's classification + * tally, backward's two protocol versions) that flow into the JSON untouched. + */ +interface IMatrixSummary { + readonly suite: string; + readonly outcome: string; + readonly durationMs: number; + readonly results: readonly IScenarioResult[]; +} + +interface IScenarioResult { + readonly scenario?: string; + readonly build?: string; + readonly currentBuild?: string; + readonly olderBuild?: string; + readonly secondBuild?: string; + readonly outcome: string; + readonly durationMs: number; + readonly protocolVersion?: string; + readonly diagnosticsPath: string; + readonly error?: string; + readonly steps: readonly IStepResult[]; +} + +interface IMatrixDefinition { + readonly id: MatrixId; + readonly title: string; + /** Module under `out/` exporting the entry point, without extension. */ + readonly module: string; + readonly run: (module: Record, options: IMatrixRunOptions) => Promise; +} + +interface IMatrixRunOptions { + readonly repoRoot: string; + readonly resolveCommit: (ref: string) => string | undefined; + readonly cacheRoot: string; + readonly diagnosticsRoot: string; + /** Builds to exercise, when the matrix takes an explicit list. */ + readonly buildIds: readonly string[] | undefined; + /** True for the reduced subset a pull request runs. */ + readonly pr: boolean; +} + +/** + * How each matrix is invoked, and what `--pr` trims from it. + * + * The PR subset is chosen to keep the *shape* of every claim while cutting + * repetition: each matrix still runs, but against the nearest checkpoint only + * (`predecessor`), because a break introduced by an in-flight change shows up + * against its immediate predecessor first. The full three-checkpoint sweep — + * which is what actually pins "oldest supported" — belongs to the scheduled + * run, where its cost is paid once a day rather than once a push. + */ +const MATRIX_DEFINITIONS: readonly IMatrixDefinition[] = [ + { + id: 'baselines', + title: 'same-build restart baselines', + module: 'agentHostLiveCompatMatrix', + run: (module, options) => { + const run = module['runSameBuildRestartBaselines'] as (ids: readonly string[], o: object) => Promise; + const ids = options.buildIds ?? (options.pr ? ['predecessor', 'current'] : builds.map(build => build.id)); + return run(ids, matrixContext(options)); + }, + }, + { + id: 'forward', + title: 'forward migrations', + module: 'runForwardMigrationMatrix', + run: (module, options) => { + const run = module['runForwardMigrations'] as (o: object) => Promise; + return run({ + ...matrixContext(options), + ...(options.pr ? { sources: ['predecessor'], includeMultiSession: true } : {}), + }); + }, + }, + { + id: 'backward', + title: 'backward-compatibility round trips', + module: 'runBackwardCompatibilityMatrix', + run: (module, options) => { + const run = module['runBackwardCompatibilityMatrixForBuilds'] as (ids: readonly string[], o: object) => Promise; + const olderBuilds = module['BACKWARD_COMPAT_OLDER_BUILDS'] as readonly string[]; + return run(options.pr ? ['predecessor'] : olderBuilds, matrixContext(options)); + }, + }, + { + id: 'recovery', + title: 'process recovery', + module: 'runRecoveryMatrix', + run: (module, options) => { + const run = module['runRecoveryMatrix'] as (ids: readonly string[], o: object) => Promise; + const ids = options.pr ? ['current'] : ['current', 'predecessor']; + return run(ids, matrixContext(options)); + }, + }, +]; + +function matrixContext(options: IMatrixRunOptions): object { + return { + repoRoot: options.repoRoot, + resolveCommit: options.resolveCommit, + cacheRoot: options.cacheRoot, + diagnosticsRoot: options.diagnosticsRoot, + }; +} + +/** + * Run the requested matrices in order and retain a JSON summary for each. + * + * Two properties are load-bearing and are the reason this is not a shell loop + * over four commands: + * + * - **Sequential.** A single `await` chain, with no concurrency anywhere, so + * two compiled Agent Host trees never contend for temp space or ports. + * - **Nothing is silently dropped.** A matrix that throws is recorded as a + * failed summary and the remaining matrices still run, so one broken matrix + * cannot hide the state of the others; the process still exits nonzero. + */ +async function runMatrices(options: IOptions): Promise { + const outputDir = resolve(repoRoot, options.outputDir); + mkdirSync(outputDir, { recursive: true }); + const diagnosticsRoot = join(outputDir, 'diagnostics'); + mkdirSync(diagnosticsRoot, { recursive: true }); + for (const id of options.runBuilds ?? []) { + lookup(id); + } + + const startedAt = Date.now(); + const written: { id: MatrixId; outcome: string; durationMs: number; summaryPath: string }[] = []; + for (const id of options.matrices) { + const definition = MATRIX_DEFINITIONS.find(candidate => candidate.id === id)!; + console.log(`\n[live-compat] running ${definition.title}${options.pr ? ' (pr subset)' : ''}`); + const summary = await runMatrix(definition, { + repoRoot, + // The non-throwing resolver: an absent checkpoint becomes that + // build's own "not ready" row, carrying the prepare-or-re-pin + // advice, instead of aborting the whole matrix with a git error. + resolveCommit: ref => tryResolveCommit(ref), + cacheRoot: cacheRoot(), + diagnosticsRoot, + buildIds: options.runBuilds, + pr: options.pr, + }); + printSummary(definition, summary); + const summaryPath = options.jsonPath ? resolve(repoRoot, options.jsonPath) : join(outputDir, SUMMARY_FILE_NAMES[id]); + mkdirSync(dirname(summaryPath), { recursive: true }); + writeFileSync(summaryPath, `${JSON.stringify(summary, undefined, '\t')}\n`); + console.log(` summary written to ${summaryPath}`); + written.push({ id, outcome: summary.outcome, durationMs: summary.durationMs, summaryPath }); + } + + const outcome = written.every(entry => entry.outcome === 'passed') ? 'passed' : 'failed'; + if (options.matrices.length > 1) { + const runPath = join(outputDir, 'run.json'); + writeFileSync(runPath, `${JSON.stringify({ + suite: 'agent-host-live-compat', + subset: options.pr ? 'pr' : 'full', + startedAt: new Date(startedAt).toISOString(), + durationMs: Date.now() - startedAt, + outcome, + matrices: written, + }, undefined, '\t')}\n`); + console.log('\nAgent Host live-compat — run summary'); + for (const entry of written) { + console.log(` ${entry.id.padEnd(10)} ${entry.outcome.toUpperCase().padEnd(6)} ${formatDuration(entry.durationMs)}`); + } + console.log(` overall: ${outcome.toUpperCase()} in ${formatDuration(Date.now() - startedAt)}`); + console.log(` run summary written to ${runPath}`); + } + if (outcome !== 'passed') { + process.exitCode = 1; + } +} + +/** + * Load a matrix from `out/` and run it, turning a throw into a failed summary. + * + * A missing module means the working tree was never transpiled, which is worth + * saying plainly rather than surfacing as a module-resolution stack. The + * scenario modules are ESM under `out/`, so they are reached with a dynamic + * import from this CommonJS script. + */ +async function runMatrix(definition: IMatrixDefinition, options: IMatrixRunOptions): Promise { + const startedAt = Date.now(); + const modulePath = join(repoRoot, OUT_LIVE_COMPAT_DIR, `${definition.module}.js`); + try { + if (!existsSync(modulePath)) { + throw new Error(`Missing ${modulePath}. Run 'npm run transpile-client' first.`); + } + const module = await import(pathToFileURL(modulePath).href) as Record; + return await definition.run(module, options); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { + suite: `agent-host-live-compat/${definition.id}`, + outcome: 'failed', + durationMs: Date.now() - startedAt, + results: [{ + scenario: definition.id, + outcome: 'failed', + durationMs: Date.now() - startedAt, + diagnosticsPath: '', + error: detail, + steps: [{ name: 'load-matrix', outcome: 'failed', durationMs: Date.now() - startedAt, detail }], + }], + }; + } +} + +function printSummary(definition: IMatrixDefinition, summary: IMatrixSummary): void { + console.log(`\nAgent Host live-compat — ${definition.title}`); + for (const result of summary.results) { + console.log(` ${labelOf(result).padEnd(34)} ${result.outcome.toUpperCase().padEnd(6)} ${formatDuration(result.durationMs)}${result.protocolVersion ? ` protocol=${result.protocolVersion}` : ''}`); + for (const step of result.steps) { + console.log(` ${step.outcome.padEnd(7)} ${step.name}${step.detail ? ` — ${step.detail}` : ''}`); + } + if (result.diagnosticsPath) { + console.log(` diagnostics: ${result.diagnosticsPath}`); + } + } + console.log(` ${definition.id}: ${summary.outcome.toUpperCase()} in ${formatDuration(summary.durationMs)}`); +} + +/** Name a scenario entry across the four differently-shaped result types. */ +function labelOf(result: IScenarioResult): string { + const build = result.currentBuild && result.olderBuild + ? `${result.currentBuild}->${result.olderBuild}` + : result.secondBuild + ? `${result.build}->${result.secondBuild}` + : result.build ?? ''; + return result.scenario ? `${build} ${result.scenario}`.trim() : build; +} + +function formatDuration(durationMs: number): string { + return durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`; +} + + +function printStatus(): void { + console.log(`Agent Host live-compat builds (cache root: ${cacheRoot()})`); + for (const build of builds) { + const state = isReady(build); + const status = state.ready ? 'ready' : `NOT READY (${state.reason})`; + console.log(` ${build.id.padEnd(13)} ${build.ref ?? 'working tree'} ${status}`); + console.log(` ${''.padEnd(13)} ${sourceRootFor(build)}`); + } +} + +main().catch(error => { + console.error(`[live-compat] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts index a4d02ca252de69..1184d381b787c1 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts @@ -3,37 +3,28 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { URI } from '../../../base/common/uri.js'; import { AgentSession, type IAgentSessionMetadata } from '../common/agent.js'; -import { SessionArtifactType, withSessionArtifacts } from '../common/sessionArtifacts.js'; -import { SessionSourceControlOutcome, SessionStatus, withSessionEhcliAdoptable, withSessionEhcliAdopted, withSessionExternal, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless } from '../common/state/sessionState.js'; -import { AGENT_HOST_CATALOG_PROJECTION_VERSION, parseAgentHostDatabaseCatalog, type IAgentHostCatalogSource } from './agentHostCatalogProjection.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'; -const artifactTypes = { - pullRequest: SessionArtifactType.PullRequest, - issue: SessionArtifactType.Issue, - commit: SessionArtifactType.Commit, - website: SessionArtifactType.Website, - file: SessionArtifactType.File, - resource: SessionArtifactType.Resource, -} as const; - -export type AgentHostCatalogListIneligibilityReason = - | 'missingCatalog' - | 'chatBacking' - | 'identityMismatch' - | 'providerMismatch' - | 'outdated' - | 'malformed' - | 'readError'; - export type AgentHostCatalogListResult = - | { readonly eligible: true; readonly metadata: IAgentSessionMetadata; readonly source: IAgentHostCatalogSource } - | { readonly eligible: false; readonly reason: Exclude } - | { readonly eligible: false; readonly reason: 'readError'; readonly error: Error }; + /** 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) { } @@ -43,78 +34,60 @@ export class AgentHostCatalogListReader { try { const catalog = await this._catalogDatabase.getSessionV2(session); if (!catalog) { - return { eligible: false, reason: 'missingCatalog' }; + return ineligible('no central row'); } if (catalog.session !== session) { - return { eligible: false, reason: 'identityMismatch' }; + return ineligible(`central row identity ${catalog.session} does not match`); } if (catalog.isChatBacking) { - return { eligible: false, reason: 'chatBacking' }; + return { eligible: false, chatBacking: true }; } if (AgentSession.provider(registered.session) !== registered.provider || catalog.provider !== registered.provider) { - return { eligible: false, reason: 'providerMismatch' }; + return ineligible(`central row provider ${catalog.provider} does not match ${registered.provider}`); } - if (catalog.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { - return { eligible: false, reason: 'outdated' }; + if (catalog.payloadVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION) { + return ineligible(`central row payload version ${catalog.payloadVersion} is outdated`); } - const parsed = parseAgentHostDatabaseCatalog(catalog); - if (!parsed.ok) { - return { eligible: false, reason: 'malformed' }; + const decoded = decodeAgentHostCatalogPayload(catalog.payload); + if (!decoded.ok) { + return ineligible(`central payload is ${decoded.reason}: ${decoded.error}`); } - return { - eligible: true, - metadata: this._toSessionMetadata(registered, parsed.value.source), - source: parsed.value.source, - }; + // 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, - reason: 'readError', + chatBacking: false, + detail: 'central row read failed', error: error instanceof Error ? error : new Error(String(error)), }; } } - private _toSessionMetadata(registered: IRegisteredSession, source: IAgentHostCatalogSource): IAgentSessionMetadata { - let status = withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsRead, source.isRead); - status = withSessionStatusFlag(status, SessionStatus.IsArchived, source.isArchived); - - let meta = withSessionExternal(undefined, registered.external); - meta = withSessionWorkspaceless(meta, source.workspaceless); - if (source.ehcliAdoptable) { - meta = withSessionEhcliAdoptable(meta); - } - meta = withSessionEhcliAdopted(meta, source.ehcliAdopted === true); - meta = withSessionMultiRootMetadata(meta, source.multiRoot); - meta = withSessionFolderPickerDecision(meta, source.folderPicker); - meta = withSessionGitHubState(meta, source.github); - meta = withSessionGitState(meta, source.git); - meta = withSessionSourceControlState(meta, source.sourceControl ? { - merge: source.sourceControl.merge, - latestOutcome: source.sourceControl.latestOutcome === 'merge' - ? SessionSourceControlOutcome.Merge - : source.sourceControl.latestOutcome === 'pullRequest' - ? SessionSourceControlOutcome.PullRequest - : undefined, - } : undefined); - meta = withSessionArtifacts(meta, source.artifacts?.map(artifact => ({ - ...artifact, - type: artifactTypes[artifact.type], - })) ?? []); - if (source.orchestration) { - meta = withSessionOrchestration(meta, source.orchestration); - } - + 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, - modifiedTime: source.modifiedTime, - summary: source.title, + modifiedTime: data.modifiedTime, + summary: data.summary, status, - project: source.project ? { uri: URI.parse(source.project.uri), displayName: source.project.displayName } : undefined, - workingDirectories: source.workingDirectories.map(directory => URI.parse(directory)), - changes: source.changes, + 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 index 0cd3a05f81c15f..9777b025d85260 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -4,835 +4,453 @@ *--------------------------------------------------------------------------------------------*/ import { createHash } from 'crypto'; +import { IJSONSchema } from '../../../base/common/jsonSchema.js'; import { stableStringify } from '../../../base/common/objects.js'; -import type { AgentHostCatalogChatKind, AgentHostCatalogTitleSource, IAgentHostDatabaseCatalogChat, IAgentHostDatabaseSessionV2Projection } from './agentHostDatabase.js'; +import { URI } from '../../../base/common/uri.js'; +import { IValidator, ValidationError, ValidatorBase, ValidatorType, vArray, vBoolean, vEnum, vObj, vOptionalProp } from '../../../base/common/validation.js'; +import { SESSION_META_ARTIFACTS_KEY } from '../common/sessionArtifacts.js'; +import { 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_ORCHESTRATION_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY } from '../common/state/sessionState.js'; -export const AGENT_HOST_CATALOG_PROJECTION_VERSION = 5; - -/** Each GitHub URL history is truncated to this many list-visible references. */ +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_STRUCTURED_FIELD_BYTE_LIMIT = 64 * 1024; -export const AGENT_HOST_CATALOG_SOURCE_PAYLOAD_BYTE_LIMIT = 4 * 1024 * 1024; +export const AGENT_HOST_CATALOG_PAYLOAD_BYTE_LIMIT = 4 * 1024 * 1024; const MAX_STRING_LENGTH = 4096; const MAX_TITLE_LENGTH = 1024; const MAX_JSON_DEPTH = 20; const MAX_JSON_ENTRIES = 2000; -export type AgentHostCatalogJsonValue = null | boolean | number | string | readonly AgentHostCatalogJsonValue[] | { readonly [key: string]: AgentHostCatalogJsonValue }; - -export interface IAgentHostCatalogProject { - readonly uri: string; - readonly displayName: string; -} - -export interface IAgentHostCatalogMultiRoot { - readonly workspaceFile: string; -} - -export interface IAgentHostCatalogFolderPickerDecision { - readonly hidden: boolean; - readonly primary?: string; -} - -export interface IAgentHostCatalogChangesSummary { - readonly additions?: number; - readonly deletions?: number; - readonly files?: number; -} - -export interface IAgentHostCatalogGitHubSummary { - readonly owner?: string; - readonly repo?: string; - readonly pullRequestUrls?: readonly string[]; - readonly initialPullRequestUrls?: readonly string[]; - readonly associatedPullRequestUrls?: readonly string[]; - readonly issueUrls?: readonly string[]; - readonly pullRequestBranchName?: string; -} - -export interface IAgentHostCatalogGitSummary { - readonly hasGitHubRemote?: boolean; - readonly branchName?: string; - readonly baseBranchName?: string; - readonly upstreamBranchName?: string; - readonly incomingChanges?: number; - readonly outgoingChanges?: number; - readonly uncommittedChanges?: number; - readonly hasBaseBranchChanges?: boolean; - readonly githubOwner?: string; - readonly githubHeadOwner?: string; - readonly githubRepo?: string; -} - -export type AgentHostCatalogSourceControlOutcome = 'merge' | 'pullRequest'; - -export interface IAgentHostCatalogSourceControlSummary { - readonly merge?: { - readonly commit: string; - }; - readonly latestOutcome?: AgentHostCatalogSourceControlOutcome; -} - -export type AgentHostCatalogArtifactType = 'pullRequest' | 'issue' | 'commit' | 'website' | 'file' | 'resource'; - -export interface IAgentHostCatalogArtifact { - readonly id: string; - readonly type: AgentHostCatalogArtifactType; - readonly label: string; - readonly link?: string; - readonly uri?: string; - readonly commitHash?: string; - readonly isGitHub?: boolean; - readonly createdByThisSession?: boolean; -} - -export interface IAgentHostCatalogOrchestration { - readonly parentSession: string; - readonly creatorSession: string; - readonly label?: string; - readonly coordinateWithCreator: boolean; - readonly notifyOnIdle?: 'once' | 'always'; - readonly creatorNotificationState?: 'waitingForCompletion' | 'notified'; -} - -export interface IAgentHostCatalogSourceChat { - readonly uri: string; - readonly order: number; - readonly kind: AgentHostCatalogChatKind; - readonly title?: string; - readonly titleSource?: AgentHostCatalogTitleSource; - readonly origin?: AgentHostCatalogJsonValue; -} - -/** - * Provider-neutral, list-visible session state. Hydrate-on-open content and - * transient activity state intentionally have no representation in this type. - */ -export interface IAgentHostCatalogSource { - readonly modifiedTime: number; - readonly title?: string; - readonly titleSource?: AgentHostCatalogTitleSource; - readonly isRead: boolean; - readonly isArchived: boolean; - readonly project?: IAgentHostCatalogProject; - readonly workspaceless: boolean; - readonly isChatBacking?: boolean; - readonly ehcliAdoptable?: boolean; - readonly ehcliAdopted?: boolean; - readonly multiRoot?: IAgentHostCatalogMultiRoot; - readonly folderPicker?: IAgentHostCatalogFolderPickerDecision; - readonly changes?: IAgentHostCatalogChangesSummary; - readonly github?: IAgentHostCatalogGitHubSummary; - readonly git?: IAgentHostCatalogGitSummary; - readonly sourceControl?: IAgentHostCatalogSourceControlSummary; - readonly artifacts?: readonly IAgentHostCatalogArtifact[]; - readonly orchestration?: IAgentHostCatalogOrchestration; - readonly workingDirectories: readonly string[]; - readonly chats: readonly IAgentHostCatalogSourceChat[]; -} - -export interface IAgentHostCatalogProjectionOptions { - readonly session: string; - readonly sessionGeneration: string; - readonly sourceRevision: number; -} - -export interface IAgentHostCatalogProjection { - readonly catalog: IAgentHostDatabaseSessionV2Projection; - readonly source: IAgentHostCatalogSource; - readonly sourcePayload: string; -} - -export interface IAgentHostCatalogValidationError { - readonly field: string; - readonly message: string; -} - -export type AgentHostCatalogValidationResult = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: IAgentHostCatalogValidationError }; - -class CatalogValidationError extends Error { - constructor(readonly field: string, message: string) { - super(message); +class RefinedValidator extends ValidatorBase { + constructor( + private readonly validator: IValidator, + private readonly refine: (value: T, original: unknown) => T | ValidationError, + ) { + super(); } -} - -const artifactTypes: ReadonlySet = new Set(['pullRequest', 'issue', 'commit', 'website', 'file', 'resource']); -const titleSources: ReadonlySet = new Set(['user', 'agent', 'auto']); -const chatKinds: ReadonlySet = new Set(['default', 'peer']); - -export function projectAgentHostCatalog(source: IAgentHostCatalogSource, options: IAgentHostCatalogProjectionOptions): AgentHostCatalogValidationResult { - return validate(() => { - const normalizedSource = normalizeSource(source); - const normalizedOptions = normalizeOptions(options); - const sourcePayload = stableStringify({ - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - source: normalizedSource, - }); - if (!sourcePayload) { - fail('sourcePayload', 'Could not serialize the catalog source payload.'); - } - assertByteLength('sourcePayload', sourcePayload, AGENT_HOST_CATALOG_SOURCE_PAYLOAD_BYTE_LIMIT); - - const sourceHash = createHash('sha256').update(sourcePayload, 'utf8').digest('hex'); - const chats: readonly IAgentHostDatabaseCatalogChat[] = normalizedSource.chats.map(chat => ({ - uri: chat.uri, - order: chat.order, - kind: chat.kind, - title: chat.title, - titleSource: chat.titleSource, - originJson: stringifyStructuredField(`chats[${chat.order}].origin`, chat.origin), - })); - const catalog: IAgentHostDatabaseSessionV2Projection = { - session: normalizedOptions.session, - sessionGeneration: normalizedOptions.sessionGeneration, - modifiedTime: normalizedSource.modifiedTime, - title: normalizedSource.title, - titleSource: normalizedSource.titleSource, - isRead: normalizedSource.isRead, - isArchived: normalizedSource.isArchived, - projectUri: normalizedSource.project?.uri, - projectDisplayName: normalizedSource.project?.displayName, - workspaceless: normalizedSource.workspaceless, - isChatBacking: normalizedSource.isChatBacking ?? false, - ehcliAdoptable: normalizedSource.ehcliAdoptable, - ehcliAdopted: normalizedSource.ehcliAdopted, - multiRootJson: stringifyStructuredField('multiRoot', normalizedSource.multiRoot), - folderPickerJson: stringifyStructuredField('folderPicker', normalizedSource.folderPicker), - changesSummaryJson: stringifyStructuredField('changes', normalizedSource.changes), - githubSummaryJson: stringifyStructuredField('github', normalizedSource.github), - gitSummaryJson: stringifyStructuredField('git', normalizedSource.git), - sourceControlSummaryJson: stringifyStructuredField('sourceControl', normalizedSource.sourceControl), - artifactsJson: stringifyStructuredField('artifacts', normalizedSource.artifacts), - orchestrationJson: stringifyStructuredField('orchestration', normalizedSource.orchestration), - sourceRevision: normalizedOptions.sourceRevision, - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - sourceHash, - verified: true, - workingDirectoriesJson: stringifyRequiredStructuredField('workingDirectories', normalizedSource.workingDirectories), - chatsJson: stringifyRequiredStructuredField('chats', chats), - }; - return { catalog, source: normalizedSource, sourcePayload }; - }); -} -export function parseAgentHostCatalogSourcePayload(payload: string): AgentHostCatalogValidationResult> { - return validate(() => { - const parsed = parseJson('sourcePayload', payload, AGENT_HOST_CATALOG_SOURCE_PAYLOAD_BYTE_LIMIT); - const raw = requirePlainObject('sourcePayload', parsed); - requireExactKeys('sourcePayload', raw, ['projectionVersion', 'source']); - if (raw['projectionVersion'] !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { - fail('sourcePayload.projectionVersion', `Expected projection version ${AGENT_HOST_CATALOG_PROJECTION_VERSION}.`); + validate(content: unknown): { content: T; error: undefined } | { content: undefined; error: ValidationError } { + const result = this.validator.validate(content); + if (result.error) { + return result; } - const source = normalizeSource(raw['source']); - const sourcePayload = stableStringify({ - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - source, - }); - if (payload !== sourcePayload) { - fail('sourcePayload', 'Catalog source payload is not canonical.'); - } - return { source, sourcePayload }; - }); -} - -export function parseAgentHostDatabaseCatalog(catalog: IAgentHostDatabaseSessionV2Projection): AgentHostCatalogValidationResult { - return validate(() => { - const source = sourceFromCatalog(catalog); - const projected = unwrap(projectAgentHostCatalog(source, { - session: catalog.session, - sessionGeneration: catalog.sessionGeneration, - sourceRevision: catalog.sourceRevision, - })); - requireCatalogEqual(catalog, projected.catalog); - return projected; - }); -} - -function sourceFromCatalog(catalog: IAgentHostDatabaseSessionV2Projection): IAgentHostCatalogSource { - if (catalog.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { - fail('projectionVersion', `Expected projection version ${AGENT_HOST_CATALOG_PROJECTION_VERSION}.`); - } - const project = catalog.projectUri === undefined && catalog.projectDisplayName === undefined - ? undefined - : { - uri: requireString('projectUri', catalog.projectUri, MAX_STRING_LENGTH), - displayName: requireString('projectDisplayName', catalog.projectDisplayName, MAX_TITLE_LENGTH), - }; - return { - modifiedTime: catalog.modifiedTime, - title: catalog.title, - titleSource: catalog.titleSource, - isRead: catalog.isRead, - isArchived: catalog.isArchived, - project, - workspaceless: catalog.workspaceless, - isChatBacking: catalog.isChatBacking, - ehcliAdoptable: catalog.ehcliAdoptable ?? false, - ehcliAdopted: catalog.ehcliAdopted ?? false, - multiRoot: parseOptionalStructuredField('multiRootJson', catalog.multiRootJson), - folderPicker: parseOptionalStructuredField('folderPickerJson', catalog.folderPickerJson), - changes: parseOptionalStructuredField('changesSummaryJson', catalog.changesSummaryJson), - github: parseOptionalStructuredField('githubSummaryJson', catalog.githubSummaryJson), - git: parseOptionalStructuredField('gitSummaryJson', catalog.gitSummaryJson), - sourceControl: parseOptionalStructuredField('sourceControlSummaryJson', catalog.sourceControlSummaryJson), - artifacts: parseOptionalStructuredField('artifactsJson', catalog.artifactsJson), - orchestration: parseOptionalStructuredField('orchestrationJson', catalog.orchestrationJson), - workingDirectories: normalizeWorkingDirectories(parseJson('workingDirectoriesJson', catalog.workingDirectoriesJson)), - chats: parseDatabaseChats(catalog.chatsJson).map(chat => { - return { - uri: chat.uri, - order: chat.order, - kind: chat.kind, - title: chat.title, - titleSource: chat.titleSource, - origin: chat.origin, - }; - }), - }; -} - -function normalizeSource(value: unknown): IAgentHostCatalogSource { - const raw = requirePlainObject('source', value); - requireExactKeys('source', raw, [ - 'modifiedTime', 'title', 'titleSource', 'isRead', 'isArchived', 'project', 'workspaceless', 'isChatBacking', - 'ehcliAdoptable', 'ehcliAdopted', 'multiRoot', 'folderPicker', 'changes', 'github', 'git', 'sourceControl', 'artifacts', 'orchestration', - 'workingDirectories', 'chats' - ]); - const workingDirectories = normalizeWorkingDirectories(raw['workingDirectories']); - const chats = normalizeChats(raw['chats']); - return { - modifiedTime: requireSafeInteger('modifiedTime', raw['modifiedTime'], 0), - title: optionalString('title', raw['title'], MAX_TITLE_LENGTH), - titleSource: optionalTitleSource('titleSource', raw['titleSource']), - isRead: requireBoolean('isRead', raw['isRead']), - isArchived: requireBoolean('isArchived', raw['isArchived']), - project: normalizeProject(raw['project']), - workspaceless: requireBoolean('workspaceless', raw['workspaceless']), - isChatBacking: optionalBoolean('isChatBacking', raw['isChatBacking']) ?? false, - ehcliAdoptable: optionalBoolean('ehcliAdoptable', raw['ehcliAdoptable']) ?? false, - ehcliAdopted: optionalBoolean('ehcliAdopted', raw['ehcliAdopted']) ?? false, - multiRoot: normalizeMultiRoot(raw['multiRoot']), - folderPicker: normalizeFolderPicker(raw['folderPicker']), - changes: normalizeChanges(raw['changes']), - github: normalizeGitHub(raw['github']), - git: normalizeGit(raw['git']), - sourceControl: normalizeSourceControl(raw['sourceControl']), - artifacts: normalizeArtifacts(raw['artifacts']), - orchestration: normalizeOrchestration(raw['orchestration']), - workingDirectories, - chats, - }; -} - -function normalizeOptions(value: IAgentHostCatalogProjectionOptions): IAgentHostCatalogProjectionOptions { - const raw = requirePlainObject('options', value); - requireExactKeys('options', raw, ['session', 'sessionGeneration', 'sourceRevision']); - return { - session: requireString('options.session', raw['session'], MAX_STRING_LENGTH), - sessionGeneration: requireString('options.sessionGeneration', raw['sessionGeneration'], MAX_STRING_LENGTH), - sourceRevision: requireSafeInteger('options.sourceRevision', raw['sourceRevision'], 0), - }; -} - -function normalizeProject(value: unknown): IAgentHostCatalogProject | undefined { - if (value === undefined) { - return undefined; - } - const raw = requirePlainObject('project', value); - requireExactKeys('project', raw, ['uri', 'displayName']); - return { - uri: requireString('project.uri', raw['uri'], MAX_STRING_LENGTH), - displayName: requireString('project.displayName', raw['displayName'], MAX_TITLE_LENGTH), - }; -} - -function normalizeMultiRoot(value: unknown): IAgentHostCatalogMultiRoot | undefined { - if (value === undefined) { - return undefined; - } - const raw = requirePlainObject('multiRoot', value); - requireExactKeys('multiRoot', raw, ['workspaceFile']); - return { workspaceFile: requireString('multiRoot.workspaceFile', raw['workspaceFile'], MAX_STRING_LENGTH) }; -} - -function normalizeFolderPicker(value: unknown): IAgentHostCatalogFolderPickerDecision | undefined { - if (value === undefined) { - return undefined; - } - const raw = requirePlainObject('folderPicker', value); - requireExactKeys('folderPicker', raw, ['hidden', 'primary']); - const hidden = requireBoolean('folderPicker.hidden', raw['hidden']); - const primary = optionalString('folderPicker.primary', raw['primary'], MAX_STRING_LENGTH); - if (primary !== undefined && !hidden) { - fail('folderPicker.primary', 'A pinned primary directory requires hidden to be true.'); + const refined = this.refine(result.content, content); + return isRefinementError(refined) + ? { content: undefined, error: refined } + : { content: refined, error: undefined }; } - return primary === undefined ? { hidden } : { hidden, primary }; -} - -function normalizeChanges(value: unknown): IAgentHostCatalogChangesSummary | undefined { - if (value === undefined) { - return undefined; - } - const raw = requirePlainObject('changes', value); - requireExactKeys('changes', raw, ['additions', 'deletions', 'files']); - return { - additions: optionalSafeInteger('changes.additions', raw['additions'], 0), - deletions: optionalSafeInteger('changes.deletions', raw['deletions'], 0), - files: optionalSafeInteger('changes.files', raw['files'], 0), - }; -} -function normalizeGitHub(value: unknown): IAgentHostCatalogGitHubSummary | undefined { - if (value === undefined) { - return undefined; + getJSONSchema(): IJSONSchema { + return this.validator.getJSONSchema(); } - const raw = requirePlainObject('github', value); - requireExactKeys('github', raw, [ - 'owner', 'repo', 'pullRequestUrls', 'initialPullRequestUrls', 'associatedPullRequestUrls', - 'issueUrls', 'pullRequestBranchName' - ]); - return { - owner: optionalString('github.owner', raw['owner'], MAX_TITLE_LENGTH), - repo: optionalString('github.repo', raw['repo'], MAX_TITLE_LENGTH), - pullRequestUrls: normalizeGitHubReferences('github.pullRequestUrls', raw['pullRequestUrls']), - initialPullRequestUrls: normalizeGitHubReferences('github.initialPullRequestUrls', raw['initialPullRequestUrls']), - associatedPullRequestUrls: normalizeGitHubReferences('github.associatedPullRequestUrls', raw['associatedPullRequestUrls']), - issueUrls: normalizeGitHubReferences('github.issueUrls', raw['issueUrls']), - pullRequestBranchName: optionalString('github.pullRequestBranchName', raw['pullRequestBranchName'], MAX_TITLE_LENGTH), - }; } -function normalizeGit(value: unknown): IAgentHostCatalogGitSummary | undefined { - if (value === undefined) { - return undefined; +class StringValidator extends ValidatorBase { + constructor( + private readonly maximumLength: number, + private readonly uri: boolean, + ) { + super(); } - const raw = requirePlainObject('git', value); - requireExactKeys('git', raw, [ - 'hasGitHubRemote', 'branchName', 'baseBranchName', 'upstreamBranchName', 'incomingChanges', - 'outgoingChanges', 'uncommittedChanges', 'hasBaseBranchChanges', 'githubOwner', 'githubHeadOwner', - 'githubRepo' - ]); - const hasGitHubRemote = optionalBoolean('git.hasGitHubRemote', raw['hasGitHubRemote']); - const branchName = optionalString('git.branchName', raw['branchName'], MAX_TITLE_LENGTH); - const baseBranchName = optionalString('git.baseBranchName', raw['baseBranchName'], MAX_TITLE_LENGTH); - const upstreamBranchName = optionalString('git.upstreamBranchName', raw['upstreamBranchName'], MAX_TITLE_LENGTH); - const incomingChanges = optionalSafeInteger('git.incomingChanges', raw['incomingChanges'], 0); - const outgoingChanges = optionalSafeInteger('git.outgoingChanges', raw['outgoingChanges'], 0); - const uncommittedChanges = optionalSafeInteger('git.uncommittedChanges', raw['uncommittedChanges'], 0); - const hasBaseBranchChanges = optionalBoolean('git.hasBaseBranchChanges', raw['hasBaseBranchChanges']); - const githubOwner = optionalString('git.githubOwner', raw['githubOwner'], MAX_TITLE_LENGTH); - const githubHeadOwner = optionalString('git.githubHeadOwner', raw['githubHeadOwner'], MAX_TITLE_LENGTH); - const githubRepo = optionalString('git.githubRepo', raw['githubRepo'], MAX_TITLE_LENGTH); - return { - ...(hasGitHubRemote === undefined ? {} : { hasGitHubRemote }), - ...(branchName === undefined ? {} : { branchName }), - ...(baseBranchName === undefined ? {} : { baseBranchName }), - ...(upstreamBranchName === undefined ? {} : { upstreamBranchName }), - ...(incomingChanges === undefined ? {} : { incomingChanges }), - ...(outgoingChanges === undefined ? {} : { outgoingChanges }), - ...(uncommittedChanges === undefined ? {} : { uncommittedChanges }), - ...(hasBaseBranchChanges === undefined ? {} : { hasBaseBranchChanges }), - ...(githubOwner === undefined ? {} : { githubOwner }), - ...(githubHeadOwner === undefined ? {} : { githubHeadOwner }), - ...(githubRepo === undefined ? {} : { githubRepo }), - }; -} -function normalizeGitHubReferences(field: string, value: unknown): readonly string[] | undefined { - if (value === undefined) { - return undefined; - } - if (!Array.isArray(value)) { - fail(field, 'Expected an array.'); - } - const seen = new Set(); - const result: string[] = []; - for (let index = 0; index < value.length && result.length < AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT; index++) { - const reference = requireString(`${field}[${index}]`, value[index], MAX_STRING_LENGTH); - const comparisonKey = reference.toLowerCase(); - if (!seen.has(comparisonKey)) { - seen.add(comparisonKey); - result.push(reference); + 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.' } }; } - } - return result; -} - -function normalizeSourceControl(value: unknown): IAgentHostCatalogSourceControlSummary | undefined { - if (value === undefined) { - return undefined; - } - const raw = requirePlainObject('sourceControl', value); - requireExactKeys('sourceControl', raw, ['merge', 'latestOutcome']); - let merge: IAgentHostCatalogSourceControlSummary['merge']; - if (raw['merge'] !== undefined) { - const rawMerge = requirePlainObject('sourceControl.merge', raw['merge']); - requireExactKeys('sourceControl.merge', rawMerge, ['commit']); - merge = { commit: requireString('sourceControl.merge.commit', rawMerge['commit'], MAX_STRING_LENGTH) }; - } - const latestOutcome = raw['latestOutcome']; - if (latestOutcome !== undefined && latestOutcome !== 'merge' && latestOutcome !== 'pullRequest') { - fail('sourceControl.latestOutcome', 'Expected merge or pullRequest.'); - } - if (latestOutcome === 'merge' && merge === undefined) { - fail('sourceControl.merge', 'A merge outcome requires a commit.'); - } - return { merge, latestOutcome }; -} - -function normalizeArtifacts(value: unknown): readonly IAgentHostCatalogArtifact[] | undefined { - if (value === undefined) { - return undefined; - } - if (!Array.isArray(value)) { - fail('artifacts', 'Expected an array.'); - } - const ids = new Set(); - const retainedArtifacts = value.slice(-AGENT_HOST_CATALOG_ARTIFACT_LIMIT); - const retainedOffset = value.length - retainedArtifacts.length; - return retainedArtifacts.map((entry, retainedIndex) => { - const index = retainedOffset + retainedIndex; - const field = `artifacts[${index}]`; - const raw = requirePlainObject(field, entry); - requireExactKeys(field, raw, ['id', 'type', 'label', 'link', 'uri', 'commitHash', 'isGitHub', 'createdByThisSession']); - const id = requireString(`${field}.id`, raw['id'], MAX_STRING_LENGTH); - if (ids.has(id)) { - fail(`${field}.id`, `Duplicate artifact id '${id}'.`); + if (content.length > this.maximumLength) { + return { content: undefined, error: { message: `String exceeds ${this.maximumLength} characters.` } }; } - ids.add(id); - const type = raw['type']; - if (typeof type !== 'string' || !artifactTypes.has(type)) { - fail(`${field}.type`, 'Unsupported artifact type.'); + 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 { - id, - type: type as AgentHostCatalogArtifactType, - label: requireString(`${field}.label`, raw['label'], MAX_TITLE_LENGTH), - link: optionalString(`${field}.link`, raw['link'], MAX_STRING_LENGTH), - uri: optionalString(`${field}.uri`, raw['uri'], MAX_STRING_LENGTH), - commitHash: optionalString(`${field}.commitHash`, raw['commitHash'], MAX_STRING_LENGTH), - isGitHub: optionalBoolean(`${field}.isGitHub`, raw['isGitHub']), - createdByThisSession: optionalBoolean(`${field}.createdByThisSession`, raw['createdByThisSession']), - }; - }); -} - -function normalizeOrchestration(value: unknown): IAgentHostCatalogOrchestration | undefined { - if (value === undefined) { - return undefined; - } - const raw = requirePlainObject('orchestration', value); - requireExactKeys('orchestration', raw, [ - 'parentSession', 'creatorSession', 'label', 'coordinateWithCreator', 'notifyOnIdle', 'creatorNotificationState' - ]); - const notifyOnIdle = raw['notifyOnIdle']; - if (notifyOnIdle !== undefined && notifyOnIdle !== 'once' && notifyOnIdle !== 'always') { - fail('orchestration.notifyOnIdle', 'Expected once or always.'); + return { content, error: undefined }; } - const creatorNotificationState = raw['creatorNotificationState']; - if (creatorNotificationState !== undefined && creatorNotificationState !== 'waitingForCompletion' && creatorNotificationState !== 'notified') { - fail('orchestration.creatorNotificationState', 'Unsupported creator notification state.'); - } - return { - parentSession: requireString('orchestration.parentSession', raw['parentSession'], MAX_STRING_LENGTH), - creatorSession: requireString('orchestration.creatorSession', raw['creatorSession'], MAX_STRING_LENGTH), - label: optionalString('orchestration.label', raw['label'], MAX_TITLE_LENGTH), - coordinateWithCreator: requireBoolean('orchestration.coordinateWithCreator', raw['coordinateWithCreator']), - notifyOnIdle, - creatorNotificationState, - }; -} -function normalizeWorkingDirectories(value: unknown): readonly string[] { - if (!Array.isArray(value)) { - fail('workingDirectories', 'Expected an array.'); - } - if (value.length > AGENT_HOST_CATALOG_CHILD_LIMIT) { - fail('workingDirectories', `Expected at most ${AGENT_HOST_CATALOG_CHILD_LIMIT} entries.`); + getJSONSchema(): IJSONSchema { + return { type: 'string', minLength: 1, maxLength: this.maximumLength }; } - const seen = new Set(); - return value.map((entry, index) => { - const directory = requireString(`workingDirectories[${index}]`, entry, MAX_STRING_LENGTH); - if (seen.has(directory)) { - fail(`workingDirectories[${index}]`, `Duplicate working directory '${directory}'.`); - } - seen.add(directory); - return directory; - }); } -function normalizeChats(value: unknown): readonly IAgentHostCatalogSourceChat[] { - if (!Array.isArray(value)) { - fail('chats', 'Expected an array.'); - } - if (value.length > AGENT_HOST_CATALOG_CHILD_LIMIT) { - fail('chats', `Expected at most ${AGENT_HOST_CATALOG_CHILD_LIMIT} entries.`); - } - const uris = new Set(); - const orders = new Set(); - const chats = value.map((entry, index) => { - const field = `chats[${index}]`; - const raw = requirePlainObject(field, entry); - requireExactKeys(field, raw, ['uri', 'order', 'kind', 'title', 'titleSource', 'origin']); - const uri = requireString(`${field}.uri`, raw['uri'], MAX_STRING_LENGTH); - const order = requireSafeInteger(`${field}.order`, raw['order'], 0); - if (uris.has(uri)) { - fail(`${field}.uri`, `Duplicate chat URI '${uri}'.`); - } - if (orders.has(order)) { - fail(`${field}.order`, `Duplicate chat order '${order}'.`); - } - uris.add(uri); - orders.add(order); - const kind = raw['kind']; - if (typeof kind !== 'string' || !chatKinds.has(kind)) { - fail(`${field}.kind`, 'Unsupported chat kind.'); - } - const origin = raw['origin'] === undefined ? undefined : normalizeJsonValue(`${field}.origin`, raw['origin']); - return { - uri, - order, - kind: kind as AgentHostCatalogChatKind, - title: optionalString(`${field}.title`, raw['title'], MAX_TITLE_LENGTH), - titleSource: optionalTitleSource(`${field}.titleSource`, raw['titleSource']), - origin, - }; - }).sort((a, b) => a.order - b.order); - for (let index = 0; index < chats.length; index++) { - if (chats[index].order !== index) { - fail(`chats[${index}].order`, 'Chat orders must form a contiguous zero-based sequence.'); - } +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.' } }; } - return chats; -} -function parseDatabaseChats(value: string): readonly IAgentHostCatalogSourceChat[] { - const parsed = parseJson('chatsJson', value); - if (!Array.isArray(parsed)) { - fail('chatsJson', 'Expected an array.'); + getJSONSchema(): IJSONSchema { + return { type: 'integer', minimum: 0 }; } - const sourceChats = parsed.map((entry, index) => { - const field = `chatsJson[${index}]`; - const raw = requirePlainObject(field, entry); - requireExactKeys(field, raw, ['uri', 'order', 'kind', 'title', 'titleSource', 'originJson']); - const originJson = raw['originJson']; - if (originJson !== undefined && typeof originJson !== 'string') { - fail(`${field}.originJson`, 'Expected a JSON string.'); - } - return { - uri: raw['uri'], - order: raw['order'], - kind: raw['kind'], - title: raw['title'], - titleSource: raw['titleSource'], - origin: originJson === undefined ? undefined : parseJson(`${field}.originJson`, originJson), - }; - }); - return normalizeChats(sourceChats); } -function normalizeJsonValue(field: string, value: unknown): AgentHostCatalogJsonValue { - let entries = 0; - const ancestors = new Set(); - const visit = (currentField: string, current: unknown, depth: number): AgentHostCatalogJsonValue => { - if (depth > MAX_JSON_DEPTH) { - fail(currentField, `JSON nesting exceeds ${MAX_JSON_DEPTH} levels.`); - } - if (current === null || typeof current === 'boolean' || typeof current === 'string') { - if (typeof current === 'string' && current.length > MAX_STRING_LENGTH) { - fail(currentField, `String exceeds ${MAX_STRING_LENGTH} characters.`); +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.` } }; } - return current; - } - if (typeof current === 'number') { - if (!Number.isFinite(current)) { - fail(currentField, 'Expected a finite JSON number.'); + if (value === null || typeof value === 'boolean') { + return { value }; } - return current; - } - if (typeof current !== 'object') { - fail(currentField, 'Expected a JSON-serializable value.'); - } - if (ancestors.has(current)) { - fail(currentField, 'Circular JSON values are not supported.'); - } - ancestors.add(current); - let result: AgentHostCatalogJsonValue; - if (Array.isArray(current)) { - entries += current.length; - checkJsonEntryLimit(field, entries); - result = current.map((entry, index) => visit(`${currentField}[${index}]`, entry, depth + 1)); - } else { - const raw = requirePlainObject(currentField, current); - const keys = Object.keys(raw).sort(); + if (typeof value === 'string') { + return value.length <= MAX_STRING_LENGTH + ? { value } + : { error: { message: `String exceeds ${MAX_STRING_LENGTH} 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; - checkJsonEntryLimit(field, entries); - const normalized: { [key: string]: AgentHostCatalogJsonValue } = {}; + 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 > MAX_STRING_LENGTH) { - fail(currentField, `JSON key exceeds ${MAX_STRING_LENGTH} characters.`); + return { error: { message: `JSON key exceeds ${MAX_STRING_LENGTH} characters.` } }; } - normalized[key] = visit(`${currentField}.${key}`, raw[key], depth + 1); + const parsed = visit((value as Record)[key], depth + 1); + if (parsed.error) { + return parsed; + } + result[key] = parsed.value; } - result = normalized; - } - ancestors.delete(current); - return result; - }; - const normalized = visit(field, value, 0); - assertStructuredFieldSize(field, stableStringify(normalized)); - return normalized; -} - -function parseOptionalStructuredField(field: string, value: string | undefined): T | undefined { - return value === undefined ? undefined : parseJson(field, value) as T; -} + 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 = MAX_STRING_LENGTH) => new StringValidator(maximumLength, false); +const uriString = () => new StringValidator(MAX_STRING_LENGTH, 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.' }); +} + +const changesValidator = plainObject(vObj({ + additions: vOptionalProp(safeInteger()), + deletions: vOptionalProp(safeInteger()), + files: vOptionalProp(safeInteger()), +})); + +const projectValidator = plainObject(vObj({ + uri: uriString(), + displayName: boundedString(MAX_TITLE_LENGTH), +})); + +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(MAX_TITLE_LENGTH)), + repo: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + pullRequestUrls: vOptionalProp(githubReferencesValidator), + initialPullRequestUrls: vOptionalProp(githubReferencesValidator), + associatedPullRequestUrls: vOptionalProp(githubReferencesValidator), + issueUrls: vOptionalProp(githubReferencesValidator), + pullRequestBranchName: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), +})); + +const gitValidator = plainObject(vObj({ + hasGitHubRemote: vOptionalProp(vBoolean()), + branchName: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + isDetachedHead: vOptionalProp(vBoolean()), + baseBranchName: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + upstreamBranchName: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + incomingChanges: vOptionalProp(safeInteger()), + outgoingChanges: vOptionalProp(safeInteger()), + uncommittedChanges: vOptionalProp(safeInteger()), + hasBaseBranchChanges: vOptionalProp(vBoolean()), + githubOwner: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + githubHeadOwner: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + githubRepo: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), +})); + +/** 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(MAX_TITLE_LENGTH), + link: vOptionalProp(boundedString()), + uri: vOptionalProp(boundedString()), + commitHash: vOptionalProp(boundedString()), + isGitHub: vOptionalProp(vBoolean()), + createdByThisSession: 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 orchestrationValidator = plainObject(vObj({ + parentSession: uriString(), + creatorSession: uriString(), + label: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + coordinateWithCreator: vBoolean(), + notifyOnIdle: vOptionalProp(vEnum('once', 'always')), + creatorNotificationState: vOptionalProp(vEnum('waitingForCompletion', 'notified')), +})); -function stringifyStructuredField(field: string, value: AgentHostCatalogJsonValue | object | undefined): string | undefined { - if (value === undefined) { - return undefined; - } - const result = stableStringify(value); - assertStructuredFieldSize(field, result); - return result; -} +/** + * 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_ORCHESTRATION_KEY]: vOptionalProp(orchestrationValidator), + [SESSION_META_WORKSPACELESS_KEY]: vOptionalProp(vBoolean()), + [SESSION_META_EHCLI_ADOPTABLE_KEY]: vOptionalProp(vBoolean()), + [SESSION_META_EHCLI_ADOPTED_KEY]: vOptionalProp(vBoolean()), +})); + +const chatValidator = plainObject(vObj({ + uri: uriString(), + order: safeInteger(), + kind: vEnum('default', 'peer'), + summary: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + titleSource: vOptionalProp(vEnum('user', 'agent', 'auto')), + origin: vOptionalProp(jsonValue()), +})); + +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(MAX_TITLE_LENGTH)), + titleSource: vOptionalProp(vEnum('user', 'agent', 'auto')), + isRead: vBoolean(), + isArchived: vBoolean(), + project: vOptionalProp(projectValidator), + isChatBacking: vOptionalProp(vBoolean()), + workingDirectories: workingDirectoriesValidator, + changes: vOptionalProp(changesValidator), + _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 }; -function stringifyRequiredStructuredField(field: string, value: AgentHostCatalogJsonValue | object): string { - const result = stringifyStructuredField(field, value); - if (result === undefined) { - fail(field, 'Could not serialize the structured field.'); +/** + * 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 result; + return { + ok: true, + value: { + data: normalized.content, + payload, + payloadHash: hashAgentHostCatalogPayload(payload), + }, + }; } -function assertStructuredFieldSize(field: string, value: string): void { - if (!value) { - fail(field, 'Could not serialize the structured field.'); +/** 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.`); } - assertByteLength(field, value, AGENT_HOST_CATALOG_STRUCTURED_FIELD_BYTE_LIMIT); -} - -function parseJson(field: string, value: string, maximumBytes = AGENT_HOST_CATALOG_STRUCTURED_FIELD_BYTE_LIMIT): unknown { - assertByteLength(field, value, maximumBytes); + let parsed: unknown; try { - return JSON.parse(value); + parsed = JSON.parse(payload); } catch (error) { - fail(field, error instanceof Error ? error.message : 'Malformed JSON.'); - } -} - -function assertByteLength(field: string, value: string, maximumBytes: number): void { - if (Buffer.byteLength(value, 'utf8') > maximumBytes) { - fail(field, `Serialized value exceeds ${maximumBytes} bytes.`); - } -} - -function requireCatalogEqual(actual: IAgentHostDatabaseSessionV2Projection, expected: IAgentHostDatabaseSessionV2Projection): void { - const scalarFields: ReadonlyArray = [ - 'session', 'sessionGeneration', 'modifiedTime', 'title', 'titleSource', 'isRead', 'isArchived', - 'projectUri', 'projectDisplayName', 'workspaceless', 'isChatBacking', 'ehcliAdoptable', 'ehcliAdopted', 'multiRootJson', 'folderPickerJson', 'changesSummaryJson', - 'githubSummaryJson', 'gitSummaryJson', 'sourceControlSummaryJson', 'artifactsJson', 'orchestrationJson', 'sourceRevision', - 'projectionVersion', 'sourceHash', 'verified', 'workingDirectoriesJson', 'chatsJson' - ]; - for (const field of scalarFields) { - if (actual[field] !== expected[field]) { - fail(field, 'Catalog field is not canonical or does not match its source hash.'); - } - } -} - -function requirePlainObject(field: string, value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { - fail(field, 'Expected a plain object.'); - } - return value as Record; -} - -function requireExactKeys(field: string, value: Record, allowedKeys: readonly string[]): void { - const allowed = new Set(allowedKeys); - for (const key of Object.keys(value)) { - if (!allowed.has(key)) { - fail(`${field}.${key}`, 'Unexpected field.'); - } + return invalidPayload(error instanceof Error ? error.message : 'Malformed JSON.'); } -} - -function requireString(field: string, value: unknown, maximumLength: number): string { - if (typeof value !== 'string' || value.length === 0) { - fail(field, 'Expected a non-empty string.'); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return invalidPayload('Expected a payload object.'); } - if (value.length > maximumLength) { - fail(field, `String exceeds ${maximumLength} characters.`); + const payloadVersion = (parsed as Record)['payloadVersion']; + if (typeof payloadVersion !== 'number' || !Number.isSafeInteger(payloadVersion) || payloadVersion < 0) { + return invalidPayload('Expected a non-negative safe integer payloadVersion.'); } - return value; -} - -function optionalString(field: string, value: unknown, maximumLength: number): string | undefined { - return value === undefined ? undefined : requireString(field, value, maximumLength); -} - -function requireBoolean(field: string, value: unknown): boolean { - if (typeof value !== 'boolean') { - fail(field, 'Expected a boolean.'); + if (payloadVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION) { + return { ok: false, reason: 'outdated', error: `Expected payload version ${AGENT_HOST_CATALOG_PAYLOAD_VERSION}, but got ${payloadVersion}.` }; } - return value; -} - -function optionalBoolean(field: string, value: unknown): boolean | undefined { - return value === undefined ? undefined : requireBoolean(field, value); -} - -function requireSafeInteger(field: string, value: unknown, minimum: number): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) { - fail(field, `Expected a safe integer greater than or equal to ${minimum}.`); + const result = payloadValidator.validate(parsed); + if (result.error) { + return invalidPayload(result.error.message); } - return value; -} - -function optionalSafeInteger(field: string, value: unknown, minimum: number): number | undefined { - return value === undefined ? undefined : requireSafeInteger(field, value, minimum); + return { + ok: true, + value: { + data: result.content.data, + payload: stableStringify(result.content), + }, + }; } -function optionalTitleSource(field: string, value: unknown): AgentHostCatalogTitleSource | undefined { - if (value === undefined) { - return undefined; - } - if (typeof value !== 'string' || !titleSources.has(value)) { - fail(field, 'Unsupported title source.'); - } - return value as AgentHostCatalogTitleSource; +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) })), + }; } -function checkJsonEntryLimit(field: string, entries: number): void { - if (entries > MAX_JSON_ENTRIES) { - fail(field, `JSON value exceeds ${MAX_JSON_ENTRIES} entries.`); - } +export function hashAgentHostCatalogPayload(payload: string): string { + return createHash('sha256').update(payload, 'utf8').digest('hex'); } -function unwrap(result: AgentHostCatalogValidationResult): T { - if (!result.ok) { - throw new CatalogValidationError(result.error.field, result.error.message); - } - return result.value; +function invalidPayload(error: string): AgentHostCatalogPayloadResult { + return { ok: false, reason: 'invalid', error }; } -function validate(callback: () => T): AgentHostCatalogValidationResult { - try { - return { ok: true, value: callback() }; - } catch (error) { - if (error instanceof CatalogValidationError) { - return { ok: false, error: { field: error.field, message: error.message } }; +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; } - throw error; + keys.add(key); } + return true; } -function fail(field: string, message: string): never { - throw new CatalogValidationError(field, message); +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 index f978ac30fdb1f8..4feac1919f4c2c 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts @@ -6,12 +6,11 @@ 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 { equals } from '../../../base/common/objects.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionDataService } from '../common/sessionDataService.js'; -import { AGENT_HOST_CATALOG_PROJECTION_VERSION, parseAgentHostCatalogSourcePayload, projectAgentHostCatalog } from './agentHostCatalogProjection.js'; -import { AgentHostCatalogSyncResult, AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, hashAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; +import { AgentHostCatalogSyncResult, AgentHostCatalogSyncService, catalogLegacyMetadataMatches, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.js'; import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase } from './agentHostDatabase.js'; import type { IRegisteredSession } from './agentSessionRegistry.js'; import type { IAgentHostStorageService } from './agentHostStorageService.js'; @@ -198,7 +197,19 @@ export class AgentHostCatalogReconciliationService extends Disposable { try { const snapshot = await database.object.getCatalogSyncSnapshot(); let replayedRevision: number | undefined; - if (snapshot?.state === 'pending') { + // 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 replay = await this._catalogSyncService.runExclusive( session, async () => { @@ -234,37 +245,20 @@ export class AgentHostCatalogReconciliationService extends Disposable { if (token.isCancellationRequested) { return { session: sessionKey, status: 'retry', reason: 'cancelled' }; } - const metadataKeys: Record = {}; - for (const key of Object.keys(sourceResult.request.legacyMetadata)) { - metadataKeys[key] = true; - } - const persistedMetadata = await database.object.getMetadataObject(metadataKeys); - const legacyMetadataMatches = Object.entries(sourceResult.request.legacyMetadata) - .every(([key, value]) => persistedMetadata[key] === value); + const legacyMetadataMatches = await catalogLegacyMetadataMatches(database.object, sourceResult.request.legacyMetadata); if (token.isCancellationRequested) { return { session: sessionKey, status: 'retry', reason: 'cancelled' }; } const central = await this._catalogDatabase.getSessionV2(sessionKey); - if (currentSnapshot?.state === 'acknowledged' && central) { - const expected = projectAgentHostCatalog(sourceResult.request.source, { - session: sessionKey, - sessionGeneration: central.sessionGeneration, - sourceRevision: currentSnapshot.sourceRevision, - }); - if (expected.ok - && currentSnapshot.projectionVersion === AGENT_HOST_CATALOG_PROJECTION_VERSION - && currentSnapshot.payloadHash === expected.value.catalog.sourceHash - && currentSnapshot.sessionGeneration === central.sessionGeneration - && currentSnapshot.sourceRevision === central.sourceRevision - && currentSnapshot.projectionVersion === central.projectionVersion - && currentSnapshot.payloadHash === central.sourceHash - && legacyMetadataMatches - && equals(central, { ...expected.value.catalog, provider: central.provider, startTime: central.startTime, external: central.external, source: central.source })) { - return replayedRevision === undefined - ? { session: sessionKey, status: 'skipped', reason: 'synchronized' } - : { session: sessionKey, status: 'succeeded', reason: 'pendingReplayed', sourceRevision: replayedRevision }; - } + const expected = encodeAgentHostCatalogPayload(sourceResult.request.data); + if (legacyMetadataMatches + && expected.ok + && currentSnapshot?.payloadHash === expected.value.payloadHash + && matchesAcknowledgedCatalogReceipt(currentSnapshot, central)) { + return replayedRevision === undefined + ? { session: sessionKey, status: 'skipped', reason: 'synchronized' } + : { session: sessionKey, status: 'succeeded', reason: 'pendingReplayed', sourceRevision: replayedRevision }; } if (token.isCancellationRequested) { @@ -293,9 +287,12 @@ export class AgentHostCatalogReconciliationService extends Disposable { token: CancellationToken, ): Promise> { const sessionKey = session.toString(); - const parsed = parseAgentHostCatalogSourcePayload(snapshot.payload); - if (!parsed.ok || snapshot.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { - return { session: sessionKey, status: 'failed', reason: 'malformedPayload', error: parsed.ok ? 'Unsupported projection version' : `${parsed.error.field}: ${parsed.error.message}` }; + 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) { @@ -304,18 +301,6 @@ export class AgentHostCatalogReconciliationService extends Disposable { if (token.isCancellationRequested) { return { session: sessionKey, status: 'retry', reason: 'cancelled' }; } - - const projection = projectAgentHostCatalog(parsed.value.source, { - session: sessionKey, - sessionGeneration: snapshot.sessionGeneration, - sourceRevision: snapshot.sourceRevision, - }); - if (!projection.ok || projection.value.sourcePayload !== snapshot.payload || projection.value.catalog.sourceHash !== snapshot.payloadHash) { - return { session: sessionKey, status: 'failed', reason: 'payloadMismatch', error: projection.ok ? 'Payload hash does not match canonical source' : `${projection.error.field}: ${projection.error.message}` }; - } - if (token.isCancellationRequested) { - return { session: sessionKey, status: 'retry', reason: 'cancelled' }; - } if (await this._catalogDatabase.isSessionTombstoned(sessionKey)) { return { session: sessionKey, status: 'retry', reason: 'tombstoned' }; } @@ -329,7 +314,15 @@ export class AgentHostCatalogReconciliationService extends Disposable { let applyResult: AgentHostDatabaseSessionV2UpsertResult; try { - applyResult = await this._catalogDatabase.upsertSessionV2(projection.value.catalog, central?.sessionGeneration); + 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: 'failed', reason: 'centralApplyFailed', error: error instanceof Error ? error.message : String(error) }; } diff --git a/src/vs/platform/agentHost/node/agentHostCatalogShadowValidator.ts b/src/vs/platform/agentHost/node/agentHostCatalogShadowValidator.ts deleted file mode 100644 index 9f89ebd5ecd593..00000000000000 --- a/src/vs/platform/agentHost/node/agentHostCatalogShadowValidator.ts +++ /dev/null @@ -1,350 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { equals } from '../../../base/common/objects.js'; -import { ILogService } from '../../log/common/log.js'; -import { AgentSession, type IAgentSessionMetadata } from '../common/agent.js'; -import { readSessionArtifacts } from '../common/sessionArtifacts.js'; -import { isSessionStatusArchived, isSessionStatusRead, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionOrchestration, readSessionSourceControlState, readSessionWorkspaceless } from '../common/state/sessionState.js'; -import { parseAgentHostDatabaseCatalog, projectAgentHostCatalog, type IAgentHostCatalogSource } from './agentHostCatalogProjection.js'; -import type { IAgentHostDatabase } from './agentHostDatabase.js'; -import type { IRegisteredSession } from './agentSessionRegistry.js'; - -const DEFAULT_CONCURRENCY = 4; - -export type AgentHostCatalogReadMode = 'legacy' | 'shadow' | 'centralWithFallback' | 'central'; - -export const agentHostCatalogShadowDiagnosticCategories = [ - 'matched', - 'missing', - 'malformed', - 'validationError', - 'identityMismatch', - 'providerMismatch', - 'startTimeMismatch', - 'modifiedTimeMismatch', - 'titleMismatch', - 'readMismatch', - 'archiveMismatch', - 'projectMismatch', - 'workspacelessMismatch', - 'adoptableMismatch', - 'adoptedMismatch', - 'multiRootMismatch', - 'folderPickerMismatch', - 'changesMismatch', - 'githubMismatch', - 'gitMismatch', - 'sourceControlMismatch', - 'artifactsMismatch', - 'orchestrationMismatch', - 'workingDirectoriesMismatch', - 'topLevelEligibilityMismatch', - 'titleSourceNotComparable', - 'chatsNotComparable', -] as const; - -export type AgentHostCatalogShadowDiagnosticCategory = typeof agentHostCatalogShadowDiagnosticCategories[number]; - -export interface IAgentHostCatalogShadowValidationReport { - readonly total: number; - readonly counts: Readonly>; -} - -export interface IAgentHostCatalogShadowValidationReporter { - report(report: IAgentHostCatalogShadowValidationReport): void; -} - -export interface IAgentHostCatalogShadowValidatorOptions { - readonly concurrency?: number; -} - -interface ISessionValidation { - readonly categories: readonly AgentHostCatalogShadowDiagnosticCategory[]; - readonly repair: boolean; -} - -interface IValidationRequest { - readonly legacySessions: readonly IAgentSessionMetadata[]; - readonly registeredSessions: readonly IRegisteredSession[]; -} - -const repairableMismatchCategories: ReadonlySet = new Set([ - 'identityMismatch', - 'modifiedTimeMismatch', - 'titleMismatch', - 'readMismatch', - 'archiveMismatch', - 'projectMismatch', - 'workspacelessMismatch', - 'adoptableMismatch', - 'adoptedMismatch', - 'multiRootMismatch', - 'folderPickerMismatch', - 'changesMismatch', - 'githubMismatch', - 'gitMismatch', - 'sourceControlMismatch', - 'artifactsMismatch', - 'orchestrationMismatch', - 'workingDirectoriesMismatch', - 'topLevelEligibilityMismatch', -]); - -export class AgentHostCatalogShadowValidator { - - private readonly _concurrency: number; - private _activeValidation: Promise | undefined; - private _pendingValidation: IValidationRequest | undefined; - - constructor( - private readonly _catalogDatabase: IAgentHostDatabase, - private readonly _reporter: IAgentHostCatalogShadowValidationReporter, - private readonly _scheduleRepair: () => void, - private readonly _logService: ILogService, - options: IAgentHostCatalogShadowValidatorOptions = {}, - ) { - const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY; - if (!Number.isInteger(concurrency) || concurrency <= 0) { - throw new Error('Agent Host catalog shadow validation concurrency must be a positive integer'); - } - this._concurrency = concurrency; - } - - schedule(legacySessions: readonly IAgentSessionMetadata[], registeredSessions: readonly IRegisteredSession[]): void { - this._pendingValidation = { - legacySessions: [...legacySessions], - registeredSessions: [...registeredSessions], - }; - if (!this._activeValidation) { - this._startPendingValidation(); - } - } - - async validate(legacySessions: readonly IAgentSessionMetadata[], registeredSessions: readonly IRegisteredSession[]): Promise { - const registeredBySession = new Map(registeredSessions.map(registered => [registered.session.toString(), registered])); - const legacyBySession = new Map(legacySessions.map(legacy => [legacy.session.toString(), legacy])); - const limiter = new Limiter(this._concurrency); - const validations = await Promise.all([ - ...legacySessions.map(legacy => limiter.queue(async () => { - try { - return await this._validateSession(legacy, registeredBySession.get(legacy.session.toString())); - } catch { - return { categories: ['validationError', 'titleSourceNotComparable', 'chatsNotComparable'], repair: true }; - } - })), - ...registeredSessions - .filter(registered => !legacyBySession.has(registered.session.toString())) - .map(registered => limiter.queue(async () => { - try { - return await this._validateCentralOnlySession(registered); - } catch { - return { categories: ['validationError', 'titleSourceNotComparable', 'chatsNotComparable'], repair: true }; - } - })), - ]); - const counts = this._emptyCounts(); - let repair = false; - for (const validation of validations) { - repair ||= validation.repair; - for (const category of validation.categories) { - counts[category]++; - } - } - const report: IAgentHostCatalogShadowValidationReport = { total: validations.length, counts }; - this._logService.info(`[AgentHostCatalogShadowValidator] ${JSON.stringify(report)}`); - if (repair) { - try { - this._scheduleRepair(); - } catch { - this._logService.warn('[AgentHostCatalogShadowValidator] Failed to schedule catalog reconciliation'); - } - } - try { - this._reporter.report(report); - } catch { - this._logService.warn('[AgentHostCatalogShadowValidator] Diagnostic reporter failed'); - } - } - - private _startPendingValidation(): void { - const request = this._pendingValidation; - if (!request) { - return; - } - this._pendingValidation = undefined; - const validation = Promise.resolve().then(() => this.validate(request.legacySessions, request.registeredSessions)); - this._activeValidation = validation; - void validation.then( - () => this._completeValidation(validation), - () => { - this._logService.warn('[AgentHostCatalogShadowValidator] Background validation failed'); - this._completeValidation(validation); - }, - ); - } - - private _completeValidation(validation: Promise): void { - if (this._activeValidation !== validation) { - return; - } - this._activeValidation = undefined; - this._startPendingValidation(); - } - - private async _validateSession(legacy: IAgentSessionMetadata, registered: IRegisteredSession | undefined): Promise { - const categories: AgentHostCatalogShadowDiagnosticCategory[] = ['titleSourceNotComparable', 'chatsNotComparable']; - const session = legacy.session.toString(); - if (!registered) { - categories.push('missing'); - return { categories, repair: true }; - } - const catalog = await this._catalogDatabase.getSessionV2(session); - if (!catalog) { - categories.push('missing'); - return { categories, repair: true }; - } - if (catalog.session !== session) { - categories.push('identityMismatch'); - return { categories, repair: true }; - } - if (catalog.isChatBacking) { - categories.push('topLevelEligibilityMismatch'); - return { categories, repair: true }; - } - const parsed = parseAgentHostDatabaseCatalog(catalog); - if (!parsed.ok) { - categories.push('malformed'); - return { categories, repair: true }; - } - - const legacyProjection = projectAgentHostCatalog(this._legacySource(legacy), { - session, - sessionGeneration: catalog.sessionGeneration, - sourceRevision: catalog.sourceRevision, - }); - if (!legacyProjection.ok) { - categories.push('validationError'); - return { categories, repair: false }; - } - - const expected = legacyProjection.value.source; - const actual = parsed.value.source; - if (AgentSession.provider(legacy.session) !== registered.provider || registered.provider !== catalog.provider) { - categories.push('providerMismatch'); - } - if (legacy.startTime !== registered.startTime || registered.startTime !== catalog.startTime) { - categories.push('startTimeMismatch'); - } - this._compare(categories, 'modifiedTimeMismatch', expected.modifiedTime, actual.modifiedTime); - this._compare(categories, 'titleMismatch', expected.title, actual.title); - this._compare(categories, 'readMismatch', expected.isRead, actual.isRead); - this._compare(categories, 'archiveMismatch', expected.isArchived, actual.isArchived); - this._compare(categories, 'projectMismatch', expected.project, actual.project); - this._compare(categories, 'workspacelessMismatch', expected.workspaceless, actual.workspaceless); - this._compare(categories, 'adoptableMismatch', expected.ehcliAdoptable, actual.ehcliAdoptable); - this._compare(categories, 'adoptedMismatch', expected.ehcliAdopted, actual.ehcliAdopted); - this._compare(categories, 'multiRootMismatch', expected.multiRoot, actual.multiRoot); - this._compare(categories, 'folderPickerMismatch', expected.folderPicker, actual.folderPicker); - this._compare(categories, 'changesMismatch', expected.changes, actual.changes); - this._compare(categories, 'githubMismatch', expected.github, actual.github); - this._compare(categories, 'gitMismatch', expected.git, actual.git); - this._compare(categories, 'sourceControlMismatch', expected.sourceControl, actual.sourceControl); - this._compare(categories, 'artifactsMismatch', expected.artifacts, actual.artifacts); - this._compare(categories, 'orchestrationMismatch', expected.orchestration, actual.orchestration); - this._compare(categories, 'workingDirectoriesMismatch', expected.workingDirectories, actual.workingDirectories); - - const mismatches = categories.filter(category => category.endsWith('Mismatch')); - if (mismatches.length === 0) { - categories.push('matched'); - } - return { - categories, - repair: mismatches.some(category => repairableMismatchCategories.has(category)), - }; - } - - private async _validateCentralOnlySession(registered: IRegisteredSession): Promise { - const categories: AgentHostCatalogShadowDiagnosticCategory[] = ['titleSourceNotComparable', 'chatsNotComparable']; - const catalog = await this._catalogDatabase.getSessionV2(registered.session.toString()); - if (!catalog) { - categories.push('missing'); - return { categories, repair: true }; - } - if (catalog.isChatBacking) { - categories.push('matched'); - return { categories, repair: false }; - } - categories.push('topLevelEligibilityMismatch'); - return { categories, repair: true }; - } - - private _legacySource(legacy: IAgentSessionMetadata): IAgentHostCatalogSource { - return { - modifiedTime: legacy.modifiedTime, - title: legacy.summary || undefined, - isRead: isSessionStatusRead(legacy.status), - isArchived: isSessionStatusArchived(legacy.status), - project: legacy.project ? { uri: legacy.project.uri.toString(), displayName: legacy.project.displayName } : undefined, - workspaceless: readSessionWorkspaceless(legacy._meta), - ehcliAdoptable: readSessionEhcliAdoptable(legacy._meta), - ehcliAdopted: readSessionEhcliAdopted(legacy._meta), - multiRoot: readSessionMultiRootMetadata(legacy._meta), - folderPicker: readSessionFolderPickerDecision(legacy._meta), - changes: legacy.changes, - github: readSessionGitHubState(legacy._meta), - git: readSessionGitState(legacy._meta), - sourceControl: readSessionSourceControlState(legacy._meta), - artifacts: readSessionArtifacts(legacy._meta), - orchestration: readSessionOrchestration(legacy._meta), - workingDirectories: legacy.workingDirectories?.map(directory => directory.toString()) ?? [], - chats: [], - }; - } - - private _compare( - categories: AgentHostCatalogShadowDiagnosticCategory[], - category: AgentHostCatalogShadowDiagnosticCategory, - expected: unknown, - actual: unknown, - ): void { - if (!equals(expected, actual)) { - categories.push(category); - } - } - - private _emptyCounts(): Record { - return { - matched: 0, - missing: 0, - malformed: 0, - validationError: 0, - identityMismatch: 0, - providerMismatch: 0, - startTimeMismatch: 0, - modifiedTimeMismatch: 0, - titleMismatch: 0, - readMismatch: 0, - archiveMismatch: 0, - projectMismatch: 0, - workspacelessMismatch: 0, - adoptableMismatch: 0, - adoptedMismatch: 0, - multiRootMismatch: 0, - folderPickerMismatch: 0, - changesMismatch: 0, - githubMismatch: 0, - gitMismatch: 0, - sourceControlMismatch: 0, - artifactsMismatch: 0, - orchestrationMismatch: 0, - workingDirectoriesMismatch: 0, - topLevelEligibilityMismatch: 0, - titleSourceNotComparable: 0, - chatsNotComparable: 0, - }; - } -} diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index ab05b26cb70812..00e4011f13091e 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../base/common/uri.js'; -import { parseSessionArtifacts, readSessionArtifacts, stringifySessionArtifacts } from '../common/sessionArtifacts.js'; +import { parseSessionArtifacts, readSessionArtifacts, SESSION_META_ARTIFACTS_KEY, stringifySessionArtifacts } from '../common/sessionArtifacts.js'; import { META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, 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_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ISessionGitHubState, ISessionGitState, ISessionSourceControlState, parseSessionFolderPickerDecision, parseSessionMultiRootMetadata, parseSessionOrchestration, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionOrchestration, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SessionStatus, SessionSummary } from '../common/state/sessionState.js'; -import { AgentHostCatalogJsonValue, IAgentHostCatalogSource, projectAgentHostCatalog } from './agentHostCatalogProjection.js'; +import { 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_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ISessionGitHubState, ISessionGitState, ISessionSourceControlState, parseSessionFolderPickerDecision, parseSessionMultiRootMetadata, parseSessionOrchestration, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionOrchestration, readSessionSourceControlState, readSessionWorkspaceless, 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_ORCHESTRATION_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionStatus, SessionSummary } from '../common/state/sessionState.js'; +import { AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, 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'; @@ -136,48 +136,53 @@ export class AgentHostCatalogSourceResolver { : undefined; const changes = preferPersistedMetadata && metadata[META_CHANGES_SUMMARY] !== undefined ? persistedChanges : state.changes; const worktreeProject = this._dependencies.worktreeProjectFromRepositoryRoot(metadata[WORKTREE_META_REPOSITORY_ROOT]); - const source: IAgentHostCatalogSource = { + const ehcliAdoptable = readSessionEhcliAdoptable(state.meta); + const ehcliAdopted = readSessionEhcliAdopted(state.meta) || metadata[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true'; + 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), + ...(orchestration ? { [SESSION_META_ORCHESTRATION_KEY]: orchestration } : undefined), + ...(workspaceless ? { [SESSION_META_WORKSPACELESS_KEY]: true } : undefined), + ...(ehcliAdoptable ? { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true } : undefined), + ...(ehcliAdopted ? { [SESSION_META_EHCLI_ADOPTED_KEY]: true } : undefined), + }; + const data: AgentHostCatalogData = { modifiedTime: state.modifiedTime, - title: title || undefined, + summary: title || undefined, titleSource, isRead, isArchived, project: worktreeProject ? { uri: worktreeProject.uri.toString(), displayName: worktreeProject.displayName } : state.project, - workspaceless, isChatBacking: !!metadata[CHAT_BACKING_METADATA_KEY] || this._dependencies.isUnpersistedChatBacking(session), - ehcliAdoptable: readSessionEhcliAdoptable(state.meta), - ehcliAdopted: readSessionEhcliAdopted(state.meta) || metadata[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true', - multiRoot, - folderPicker, - changes, - github, - git, - sourceControl, - artifacts, - orchestration, workingDirectories: state.workingDirectories, + changes, + _meta: Object.keys(meta).length > 0 ? meta : undefined, chats: state.chats.map((chat, order) => ({ uri: chat.uri, order, kind: chat.kind, - title: (preferPersistedMetadata ? metadata[customChatTitleMetadataKey(chat.uri)] : metadataOverrides[customChatTitleMetadataKey(chat.uri)]) || chat.title || undefined, + summary: (preferPersistedMetadata ? metadata[customChatTitleMetadataKey(chat.uri)] : metadataOverrides[customChatTitleMetadataKey(chat.uri)]) || chat.title || undefined, titleSource: normalizeCatalogTitleSource(metadata[customChatTitleSourceMetadataKey(chat.uri)]), origin: chat.origin, })), }; const legacyMetadata: Record = { ...metadataOverrides, - [AH_META_IS_READ_DB_KEY]: source.isRead ? 'true' : '', - [AH_META_IS_ARCHIVED_DB_KEY]: source.isArchived ? 'true' : '', + [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), [AH_META_ORCHESTRATION_DB_KEY]: orchestration ? JSON.stringify(orchestration) : '', }; - if (source.workspaceless || metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { - legacyMetadata[AH_META_WORKSPACELESS_DB_KEY] = source.workspaceless ? 'true' : 'false'; + 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]; @@ -205,7 +210,7 @@ export class AgentHostCatalogSourceResolver { if (metadata[META_CHANGES_SUMMARY] !== undefined) { legacyMetadata[META_CHANGES_SUMMARY] = changes ? JSON.stringify(changes) : ''; } - return { source, legacyMetadata }; + return { data, legacyMetadata }; } } @@ -308,20 +313,7 @@ function readPersistedGitState(value: string | undefined): ISessionGitState | un return undefined; } try { - const projected = projectAgentHostCatalog({ - modifiedTime: 0, - isRead: false, - isArchived: false, - workspaceless: false, - git: JSON.parse(value), - workingDirectories: [], - chats: [], - }, { - session: 'agent-host-catalog-git-validation', - sessionGeneration: 'agent-host-catalog-git-validation', - sourceRevision: 0, - }); - return projected.ok ? projected.value.source.git : undefined; + return agentHostCatalogGitValidator.validate(JSON.parse(value)).content; } catch { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts index 5bc3a3cbbb8493..b775b835081ff7 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts @@ -7,14 +7,14 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionCatalogSyncSnapshot, ISessionDataService } from '../common/sessionDataService.js'; -import { AGENT_HOST_CATALOG_PROJECTION_VERSION, IAgentHostCatalogSource, projectAgentHostCatalog } from './agentHostCatalogProjection.js'; -import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseSessionV2 } from './agentHostDatabase.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, AgentHostCatalogData, encodeAgentHostCatalogPayload, IAgentHostCatalogEncodedPayload } from './agentHostCatalogProjection.js'; +import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; const INITIAL_SOURCE_REVISION = 0; const MAX_GENERATION_RETRIES = 3; export interface IAgentHostCatalogSyncRequest { - readonly source: IAgentHostCatalogSource; + readonly data: AgentHostCatalogData; readonly legacyMetadata: Readonly>; } @@ -26,6 +26,34 @@ interface IQueuedOperation { readonly run: () => Promise; } +/** + * 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); +} + interface ISessionSyncQueue { running: boolean; readonly pending: IQueuedOperation[]; @@ -85,36 +113,26 @@ export class AgentHostCatalogSyncService { private async _synchronizeNow(session: URI, request: IAgentHostCatalogSyncRequest): Promise { const sessionKey = session.toString(); + const encoded = this._encode(request.data); const ref = this._sessionDataService.openDatabase(session); try { for (let attempt = 0; attempt < MAX_GENERATION_RETRIES; attempt++) { const existing = await ref.object.getCatalogSyncSnapshot(); - let central: IAgentHostDatabaseSessionV2 | undefined; + let central: IAgentHostDatabaseSessionV2Receipt | 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 this._legacyMetadataMatches(ref.object, request.legacyMetadata); - const pending = await this._storePending(ref.object, sessionKey, request, existing, legacyMetadataMatches); + 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 this._legacyMetadataMatches(ref.object, request.legacyMetadata); - const candidate = this._project(request.source, sessionKey, sessionGeneration, INITIAL_SOURCE_REVISION); - const sourceRevision = this._sourceRevision(existing, central, sessionGeneration, candidate.catalog.sourceHash, legacyMetadataMatches); - const projection = sourceRevision === INITIAL_SOURCE_REVISION - ? candidate - : this._project(request.source, sessionKey, sessionGeneration, sourceRevision); - const snapshot: ISessionCatalogSyncPendingSnapshot = { - sessionGeneration, - sourceRevision, - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - payload: projection.sourcePayload, - payloadHash: projection.catalog.sourceHash, - state: 'pending', - }; + 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( @@ -128,8 +146,7 @@ export class AgentHostCatalogSyncService { } else { const writeResult = await ref.object.setMetadataValuesAndCatalogSyncSnapshot(request.legacyMetadata, snapshot); if (writeResult === 'replayed' - && existing?.state === 'acknowledged' - && this._matchesReceipt(central, existing) + && matchesAcknowledgedCatalogReceipt(existing, central) && legacyMetadataMatches) { return { status: 'acknowledged', sourceRevision }; } @@ -137,7 +154,10 @@ export class AgentHostCatalogSyncService { let upsertResult: AgentHostDatabaseSessionV2UpsertResult; try { - upsertResult = await this._catalogDatabase.upsertSessionV2(projection.catalog, central?.sessionGeneration); + 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' }; @@ -146,7 +166,7 @@ export class AgentHostCatalogSyncService { continue; } if (upsertResult !== 'applied' && upsertResult !== 'replayed') { - this._logService.warn(`[AgentHostCatalogSync] sessions_v2 projection for ${sessionKey} remains pending: ${upsertResult}`); + this._logService.warn(`[AgentHostCatalogSync] sessions_v2 payload for ${sessionKey} remains pending: ${upsertResult}`); return { status: 'pending', sourceRevision, reason: upsertResult }; } @@ -175,25 +195,14 @@ export class AgentHostCatalogSyncService { private async _storePending( database: ReturnType['object'], - session: string, request: IAgentHostCatalogSyncRequest, + encoded: IAgentHostCatalogEncodedPayload, existing: ISessionCatalogSyncSnapshot | undefined, legacyMetadataMatches: boolean, ): Promise { const sessionGeneration = existing?.sessionGeneration ?? generateUuid(); - const candidate = this._project(request.source, session, sessionGeneration, INITIAL_SOURCE_REVISION); - const sourceRevision = this._sourceRevision(existing, undefined, sessionGeneration, candidate.catalog.sourceHash, legacyMetadataMatches); - const projection = sourceRevision === INITIAL_SOURCE_REVISION - ? candidate - : this._project(request.source, session, sessionGeneration, sourceRevision); - const snapshot: ISessionCatalogSyncPendingSnapshot = { - sessionGeneration, - sourceRevision, - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - payload: projection.sourcePayload, - payloadHash: projection.catalog.sourceHash, - state: 'pending', - }; + 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 { @@ -202,21 +211,9 @@ export class AgentHostCatalogSyncService { return snapshot; } - private async _legacyMetadataMatches( - 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); - } - private _sourceRevision( existing: ISessionCatalogSyncSnapshot | undefined, - central: IAgentHostDatabaseSessionV2 | undefined, + central: IAgentHostDatabaseSessionV2Receipt | undefined, sessionGeneration: string, payloadHash: string, legacyMetadataMatches: boolean, @@ -227,10 +224,10 @@ export class AgentHostCatalogSyncService { local?.sourceRevision ?? INITIAL_SOURCE_REVISION, current?.sourceRevision ?? INITIAL_SOURCE_REVISION, ); - const localMatches = local?.projectionVersion === AGENT_HOST_CATALOG_PROJECTION_VERSION + const localMatches = local?.projectionVersion === AGENT_HOST_CATALOG_PAYLOAD_VERSION && local.payloadHash === payloadHash; - const centralMatches = current?.projectionVersion === AGENT_HOST_CATALOG_PROJECTION_VERSION - && current.sourceHash === 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; @@ -242,21 +239,33 @@ export class AgentHostCatalogSyncService { return local || current ? baselineRevision + 1 : INITIAL_SOURCE_REVISION; } - private _matchesReceipt(central: IAgentHostDatabaseSessionV2 | undefined, receipt: ISessionCatalogSyncSnapshot): boolean { - return central?.sessionGeneration === receipt.sessionGeneration - && central.sourceRevision === receipt.sourceRevision - && central.projectionVersion === receipt.projectionVersion - && central.sourceHash === receipt.payloadHash; + 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 _project(source: IAgentHostCatalogSource, session: string, sessionGeneration: string, sourceRevision: number) { - const result = projectAgentHostCatalog(source, { + 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 source at ${result.error.field}: ${result.error.message}`); + throw new Error(`Invalid catalog data: ${result.error}`); } return result.value; } diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index e76c6adcb345e8..2f315398940a6f 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -6,10 +6,10 @@ import * as fs from 'fs'; import type { Database, RunResult } from '@vscode/sqlite3'; import { Sequencer } from '../../../base/common/async.js'; -import { stableStringify } from '../../../base/common/objects.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. @@ -52,49 +52,26 @@ export interface IAgentHostDatabaseSessionsV2Exclusion { readonly fingerprint: string; } -export type AgentHostCatalogTitleSource = 'user' | 'agent' | 'auto'; -export type AgentHostCatalogChatKind = 'default' | 'peer'; - -export interface IAgentHostDatabaseCatalogChat { - readonly uri: string; - readonly order: number; - readonly kind: AgentHostCatalogChatKind; - readonly title: string | undefined; - readonly titleSource: AgentHostCatalogTitleSource | undefined; - readonly originJson: string | undefined; -} - -export interface IAgentHostDatabaseSessionV2Projection { +/** Durable catalog envelope written alongside the opaque, self-describing payload. */ +export interface IAgentHostDatabaseSessionV2Envelope { readonly session: string; readonly sessionGeneration: string; - readonly modifiedTime: number; - readonly title: string | undefined; - readonly titleSource: AgentHostCatalogTitleSource | undefined; - readonly isRead: boolean; - readonly isArchived: boolean; - readonly projectUri: string | undefined; - readonly projectDisplayName: string | undefined; - readonly workspaceless: boolean; - readonly isChatBacking: boolean; - readonly ehcliAdoptable?: boolean; - readonly ehcliAdopted?: boolean; - readonly multiRootJson: string | undefined; - readonly folderPickerJson: string | undefined; - readonly changesSummaryJson: string | undefined; - readonly githubSummaryJson: string | undefined; - readonly gitSummaryJson: string | undefined; - readonly sourceControlSummaryJson: string | undefined; - readonly artifactsJson: string | undefined; - readonly orchestrationJson: string | undefined; readonly sourceRevision: number; - readonly projectionVersion: number; - readonly sourceHash: string; + readonly payloadVersion: number; + readonly payloadHash: string; readonly verified: true; - readonly workingDirectoriesJson: string; - readonly chatsJson: string; + readonly payload: string; } -export interface IAgentHostDatabaseSessionV2 extends IAgentHostDatabaseSessionV2Projection, IAgentHostDatabaseSession { } +/** 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; +} + +export interface IAgentHostDatabaseSessionV2 extends IAgentHostDatabaseSessionV2Receipt { + readonly payload: string; +} export type AgentHostDatabaseSessionV2UpsertResult = 'applied' | 'replayed' | 'stale' | 'conflict' | 'generationMismatch' | 'missingSession' | 'tombstoned'; @@ -123,10 +100,10 @@ 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 projection version. */ - isSessionsV2Backfilled(provider: AgentProvider, projectionVersion: number): Promise; - /** Records that a provider completed backfill for a specific v2 projection version. */ - markSessionsV2Backfilled(provider: AgentProvider, projectionVersion: number): 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. */ @@ -166,15 +143,15 @@ export interface IAgentHostDatabase extends IDisposable { 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 projection from v2 without changing legacy. */ + /** 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. */ reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise; - /** Returns a current v2 registry identity, including one whose projection is incomplete. */ + /** Returns a current v2 registry identity, including one whose payload is incomplete. */ getSessionV2Registration(session: string): Promise; - /** Lists current v2 registry identities, including rows whose projections are incomplete. */ + /** 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; @@ -182,7 +159,9 @@ export interface IAgentHostDatabase extends IDisposable { isSessionV2RegistryEmpty(): Promise; getSessionV2(session: string): Promise; listSessionsV2(): Promise; - upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise; + /** Lists catalog receipts without materializing payloads, for startup scans. */ + listSessionsV2Receipts(): Promise; + upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise; close(): Promise; } @@ -312,6 +291,35 @@ const migrations = [ 'ALTER TABLE sessions_v2_v7 RENAME TO sessions_v2', ].join(';\n'), }, + { + version: 8, + sql: [ + `CREATE TABLE sessions_v2_v8 ( + 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)) + )`, + `INSERT INTO sessions_v2_v8 ( + session_uri, provider, start_time, external, registration_source, + session_generation, source_revision, payload_version, payload_hash, verified, payload, is_chat_backing + ) + SELECT + session_uri, provider, start_time, external, registration_source, + session_generation, source_revision, projection_version, source_hash, 0, NULL, is_chat_backing + FROM sessions_v2`, + 'DROP TABLE sessions_v2', + 'ALTER TABLE sessions_v2_v8 RENAME TO sessions_v2', + ].join(';\n'), + }, ] as const; function openDatabase(path: string): Promise { @@ -360,9 +368,9 @@ function providerBackfillKey(provider: AgentProvider): string { return `sessionRegistryBackfilled:${provider}`; } -/** Metadata key for a provider's completed current-projection backfill. */ -function sessionsV2BackfillKey(provider: AgentProvider, projectionVersion: number): string { - return `sessionsV2Backfilled:${provider}:v${projectionVersion}`; +/** 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:'; @@ -545,18 +553,18 @@ export class AgentHostDatabase implements IAgentHostDatabase { ); } - async isSessionsV2Backfilled(provider: AgentProvider, projectionVersion: number): Promise { - this._validateProjectionVersion(projectionVersion); - const row = await get(await this._ensureDatabase(), 'SELECT value FROM metadata WHERE key = ?', [sessionsV2BackfillKey(provider, projectionVersion)]); + 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, projectionVersion: number): Promise { - this._validateProjectionVersion(projectionVersion); + 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, projectionVersion)], + [sessionsV2BackfillKey(provider, payloadVersion)], ); } @@ -946,64 +954,57 @@ export class AgentHostDatabase implements IAgentHostDatabase { )`, [session, tombstoneKey(session)], ); - return row ? this._toSessionV2(row) : undefined; + return row ? { ...this._toSessionV2Receipt(row), payload: row.payload as string } : undefined; } async listSessionsV2(): Promise { - const rows = await all( - await this._ensureDatabase(), - `SELECT * - 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`, - [], - ); - return rows.map(row => this._toSessionV2(row)); + const rows = await all(await this._ensureDatabase(), this._selectVerifiedSessionsV2('*'), []); + 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, external, registration_source, + session_generation, source_revision, payload_version, payload_hash, is_chat_backing`, + ), []); + return rows.map(row => this._toSessionV2Receipt(row)); } - async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { - this._validateSessionV2Projection(projection); + 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(projection.session)]); + 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, external, registration_source FROM sessions_v2 WHERE session_uri = ?', [projection.session]); + const registry = await get(database, 'SELECT provider, start_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, projection.session)]); + 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, projection_version, source_hash, verified FROM sessions_v2 WHERE session_uri = ?', [projection.session]); + 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 === projection.sessionGeneration) { + if (currentGeneration === envelope.sessionGeneration) { const currentRevision = current?.source_revision as number; - if (projection.sourceRevision < currentRevision) { + if (envelope.sourceRevision < currentRevision) { await exec(database, 'COMMIT'); return 'stale'; } - if (projection.sourceRevision === currentRevision) { - const replayed = current?.projection_version === projection.projectionVersion && current?.source_hash === projection.sourceHash; + if (envelope.sourceRevision === currentRevision) { + const replayed = current?.payload_version === envelope.payloadVersion && current?.payload_hash === envelope.payloadHash; await exec(database, 'COMMIT'); return replayed ? 'replayed' : 'conflict'; } @@ -1011,78 +1012,36 @@ export class AgentHostDatabase implements IAgentHostDatabase { await run(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, is_chat_backing, ehcli_adoptable, ehcli_adopted, 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + 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, external = excluded.external, registration_source = excluded.registration_source, - modified_time = excluded.modified_time, - title = excluded.title, - title_source = excluded.title_source, - is_read = excluded.is_read, - is_archived = excluded.is_archived, - project_uri = excluded.project_uri, - project_display_name = excluded.project_display_name, - workspaceless = excluded.workspaceless, - is_chat_backing = excluded.is_chat_backing, - ehcli_adoptable = excluded.ehcli_adoptable, - ehcli_adopted = excluded.ehcli_adopted, - working_directories_json = excluded.working_directories_json, - chats_json = excluded.chats_json, - multi_root_json = excluded.multi_root_json, - folder_picker_json = excluded.folder_picker_json, - changes_summary_json = excluded.changes_summary_json, - github_summary_json = excluded.github_summary_json, - git_summary_json = excluded.git_summary_json, - source_control_summary_json = excluded.source_control_summary_json, - artifacts_json = excluded.artifacts_json, - orchestration_json = excluded.orchestration_json, session_generation = excluded.session_generation, source_revision = excluded.source_revision, - projection_version = excluded.projection_version, - source_hash = excluded.source_hash, - verified = excluded.verified`, [ - projection.session, + 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.external, registry.registration_source, - projection.modifiedTime, - projection.title, - projection.titleSource, - projection.isRead ? 1 : 0, - projection.isArchived ? 1 : 0, - projection.projectUri, - projection.projectDisplayName, - projection.workspaceless ? 1 : 0, - projection.isChatBacking ? 1 : 0, - projection.ehcliAdoptable === undefined ? null : projection.ehcliAdoptable ? 1 : 0, - projection.ehcliAdopted === undefined ? null : projection.ehcliAdopted ? 1 : 0, - projection.workingDirectoriesJson, - projection.chatsJson, - projection.multiRootJson, - projection.folderPickerJson, - projection.changesSummaryJson, - projection.githubSummaryJson, - projection.gitSummaryJson, - projection.sourceControlSummaryJson, - projection.artifactsJson, - projection.orchestrationJson, - projection.sessionGeneration, - projection.sourceRevision, - projection.projectionVersion, - projection.sourceHash, + 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 ${projection.session}`); + return this._rollback(database, error, `Failed to upsert sessions_v2 row for ${envelope.session}`); } }); } @@ -1116,57 +1075,66 @@ export class AgentHostDatabase implements IAgentHostDatabase { ); } - private _validateSessionV2Projection(projection: IAgentHostDatabaseSessionV2Projection): void { + /** + * 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 [ - ['modifiedTime', projection.modifiedTime], - ['sourceRevision', projection.sourceRevision], - ['projectionVersion', projection.projectionVersion], + ['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', projection.session], - ['sessionGeneration', projection.sessionGeneration], - ['sourceHash', projection.sourceHash], - ['workingDirectoriesJson', projection.workingDirectoriesJson], - ['chatsJson', projection.chatsJson], + ['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 (projection.verified !== true) { - throw new Error('Catalog projection must be verified before it is stored'); + if (envelope.verified !== true) { + throw new Error('Catalog envelope must be verified before it is stored'); } - const workingDirectories = this._validateCanonicalJson('workingDirectoriesJson', projection.workingDirectoriesJson); - const chats = this._validateCanonicalJson('chatsJson', projection.chatsJson); - if (!Array.isArray(workingDirectories) || !Array.isArray(chats)) { - throw new Error('Catalog working directories and chats must be JSON arrays'); + const decoded = decodeAgentHostCatalogPayload(envelope.payload); + if (!decoded.ok) { + throw new Error(`Catalog payload is ${decoded.reason}: ${decoded.error}`); } - for (const [name, value] of [ - ['multiRootJson', projection.multiRootJson], - ['folderPickerJson', projection.folderPickerJson], - ['changesSummaryJson', projection.changesSummaryJson], - ['githubSummaryJson', projection.githubSummaryJson], - ['gitSummaryJson', projection.gitSummaryJson], - ['sourceControlSummaryJson', projection.sourceControlSummaryJson], - ['artifactsJson', projection.artifactsJson], - ['orchestrationJson', projection.orchestrationJson], - ] as const) { - if (value !== undefined) { - this._validateCanonicalJson(name, value); - } + 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 _validateProjectionVersion(projectionVersion: number): void { - if (!Number.isSafeInteger(projectionVersion) || projectionVersion < 0) { - throw new Error('Catalog projectionVersion must be a non-negative safe integer'); + private _validatePayloadVersion(payloadVersion: number): void { + if (!Number.isSafeInteger(payloadVersion) || payloadVersion < 0) { + throw new Error('Catalog payloadVersion must be a non-negative safe integer'); } } + 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' @@ -1177,47 +1145,15 @@ export class AgentHostDatabase implements IAgentHostDatabase { return { provider, session, reason: parsed.reason, fingerprint: parsed.fingerprint }; } - private _validateCanonicalJson(name: string, value: string): unknown { - const parsed = JSON.parse(value); - if (stableStringify(parsed) !== value) { - throw new Error(`Catalog ${name} must be canonical JSON`); - } - return parsed; - } - - private _toSessionV2(row: Record): IAgentHostDatabaseSessionV2 { + private _toSessionV2Receipt(row: Record): IAgentHostDatabaseSessionV2Receipt { return { - session: row.session_uri as string, - provider: row.provider as AgentProvider, - startTime: row.start_time as number, - external: row.external === null ? undefined : row.external === 1, - source: row.registration_source as AgentSessionRegistrationSource, + ...this._toSessionRegistration(row), sessionGeneration: row.session_generation as string, - modifiedTime: row.modified_time as number, - title: row.title === null ? undefined : row.title as string, - titleSource: row.title_source === null ? undefined : row.title_source as AgentHostCatalogTitleSource, - isRead: row.is_read === 1, - isArchived: row.is_archived === 1, - projectUri: row.project_uri === null ? undefined : row.project_uri as string, - projectDisplayName: row.project_display_name === null ? undefined : row.project_display_name as string, - workspaceless: row.workspaceless === 1, - isChatBacking: row.is_chat_backing === 1, - ehcliAdoptable: row.ehcli_adoptable === null ? undefined : row.ehcli_adoptable === 1, - ehcliAdopted: row.ehcli_adopted === null ? undefined : row.ehcli_adopted === 1, - workingDirectoriesJson: row.working_directories_json as string, - chatsJson: row.chats_json as string, - multiRootJson: row.multi_root_json === null ? undefined : row.multi_root_json as string, - folderPickerJson: row.folder_picker_json === null ? undefined : row.folder_picker_json as string, - changesSummaryJson: row.changes_summary_json === null ? undefined : row.changes_summary_json as string, - githubSummaryJson: row.github_summary_json === null ? undefined : row.github_summary_json as string, - gitSummaryJson: row.git_summary_json === null ? undefined : row.git_summary_json as string, - sourceControlSummaryJson: row.source_control_summary_json === null ? undefined : row.source_control_summary_json as string, - artifactsJson: row.artifacts_json === null ? undefined : row.artifacts_json as string, - orchestrationJson: row.orchestration_json === null ? undefined : row.orchestration_json as string, sourceRevision: row.source_revision as number, - projectionVersion: row.projection_version as number, - sourceHash: row.source_hash as string, + payloadVersion: row.payload_version as number, + payloadHash: row.payload_hash as string, verified: true, + isChatBacking: row.is_chat_backing === 1, }; } diff --git a/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts index 2aa4df412ae6a6..1f949f37b90084 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts @@ -8,9 +8,9 @@ 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_PROJECTION_VERSION } from './agentHostCatalogProjection.js'; -import { IAgentHostCatalogSyncRequest, AgentHostCatalogSyncService } from './agentHostCatalogSyncService.js'; -import { AgentHostSessionsV2ExclusionReason, IAgentHostDatabase, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2 } from './agentHostDatabase.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION } from './agentHostCatalogProjection.js'; +import { AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.js'; +import { AgentHostSessionsV2ExclusionReason, IAgentHostDatabase, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; const IMPORT_CONCURRENCY = 4; @@ -25,7 +25,7 @@ export interface IAgentHostSessionsV2Candidate { readonly session: URI; readonly current: IAgentHostDatabaseSession | undefined; readonly legacy: IAgentHostDatabaseSession | undefined; - readonly catalog: IAgentHostDatabaseSessionV2 | undefined; + readonly catalog: IAgentHostDatabaseSessionV2Receipt | undefined; readonly provider: IAgentHostSessionsV2ProviderCandidate | undefined; readonly exclusion: IAgentHostDatabaseSessionsV2Exclusion | undefined; } @@ -85,7 +85,7 @@ export class AgentHostSessionsV2MigrationService { resolve: (candidate: IAgentHostSessionsV2Candidate) => Promise>, force = false, ): Promise | undefined> { - const wasBackfilled = await this._database.isSessionsV2Backfilled(provider, AGENT_HOST_CATALOG_PROJECTION_VERSION); + const wasBackfilled = await this._database.isSessionsV2Backfilled(provider, AGENT_HOST_CATALOG_PAYLOAD_VERSION); const providerCandidates = !force && wasBackfilled ? [] : await enumerate(); if (providerCandidates === undefined) { return undefined; @@ -93,7 +93,7 @@ export class AgentHostSessionsV2MigrationService { const [currentRegistrations, currentCatalog, legacyRegistrations, exclusions] = await Promise.all([ this._database.listSessionV2RegistrationsForImport(), - this._database.listSessionsV2(), + this._database.listSessionsV2Receipts(), this._database.listSessions(), this._database.listSessionsV2Exclusions(provider), ]); @@ -141,7 +141,7 @@ export class AgentHostSessionsV2MigrationService { candidate.current.external === undefined || (!!candidate.legacy && candidate.legacy.external !== undefined && !this._registrationsEqual(candidate.current, candidate.legacy)) || !candidate.catalog - || candidate.catalog.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_VERSION + || candidate.catalog.payloadVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION )); }) : providerCandidatesToMigrate; @@ -166,7 +166,7 @@ export class AgentHostSessionsV2MigrationService { }; if (report.incomplete === 0 && report.failed === 0) { if (!wasBackfilled) { - await this._database.markSessionsV2Backfilled(provider, AGENT_HOST_CATALOG_PROJECTION_VERSION); + await this._database.markSessionsV2Backfilled(provider, AGENT_HOST_CATALOG_PAYLOAD_VERSION); } return { ...report, marked: true }; } @@ -299,8 +299,8 @@ export class AgentHostSessionsV2MigrationService { }); } - private async _hasMatchingReceipt(session: URI, catalog: IAgentHostDatabaseSessionV2): Promise { - if (!catalog.verified || catalog.projectionVersion !== AGENT_HOST_CATALOG_PROJECTION_VERSION) { + 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); @@ -308,12 +308,7 @@ export class AgentHostSessionsV2MigrationService { return false; } try { - const receipt = await ref.object.getCatalogSyncSnapshot(); - return receipt?.state === 'acknowledged' - && receipt.sessionGeneration === catalog.sessionGeneration - && receipt.sourceRevision === catalog.sourceRevision - && receipt.projectionVersion === catalog.projectionVersion - && receipt.payloadHash === catalog.sourceHash; + 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 2edd44cbd1825d..5cf2db01eac6b9 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -61,10 +61,9 @@ import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, customChat import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts } from '../common/sessionArtifacts.js'; import { AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; -import { AGENT_HOST_CATALOG_PROJECTION_VERSION } from './agentHostCatalogProjection.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION } from './agentHostCatalogProjection.js'; import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult } from './agentHostCatalogReconciliationService.js'; import { IAgentHostStorageService } from './agentHostStorageService.js'; -import { AgentHostCatalogShadowValidator, type AgentHostCatalogReadMode, type IAgentHostCatalogShadowValidationReporter } from './agentHostCatalogShadowValidator.js'; import { AgentHostCatalogListReader } from './agentHostCatalogListReader.js'; import { AgentHostSessionsV2CandidateResolution, AgentHostSessionsV2MigrationService, IAgentHostSessionsV2Candidate } from './agentHostSessionsV2MigrationService.js'; @@ -350,12 +349,6 @@ export interface IAgentServiceOptions { readonly storageResource?: URI; readonly orchestratorDatabase?: IAgentHostDatabase; readonly debugLogsEnvironment?: IAgentHostDebugLogsEnvironment; - /** Selects how {@link AgentService.listSessions} reads the `sessions_v2` catalog. Defaults to `legacy`. */ - readonly catalogReadMode?: AgentHostCatalogReadMode; - /** Receives `shadow`-mode catalog validation results. */ - readonly catalogShadowReporter?: IAgentHostCatalogShadowValidationReporter; - /** Caps concurrent `shadow`-mode catalog validations. */ - readonly catalogShadowConcurrency?: number; } export interface IAgentServiceCallbacks { @@ -409,12 +402,6 @@ export interface IAgentServiceCore { readonly configurationService: AgentConfigurationService; readonly agents: ISettableObservable; readonly callbackBinder: IAgentServiceCallbackBinder; - /** Selects how {@link AgentService.listSessions} reads the `sessions_v2` catalog. Defaults to `legacy`. */ - readonly catalogReadMode?: AgentHostCatalogReadMode; - /** Receives `shadow`-mode catalog validation results. */ - readonly catalogShadowReporter?: IAgentHostCatalogShadowValidationReporter; - /** Caps concurrent `shadow`-mode catalog validations. */ - readonly catalogShadowConcurrency?: number; } /** @@ -449,12 +436,10 @@ export class AgentService extends Disposable implements IAgentService { */ private readonly _sessionRegistry: AgentSessionRegistry; private readonly _orchestratorDatabase: IAgentHostDatabase; - private readonly _catalogReadMode: AgentHostCatalogReadMode; private readonly _catalogSyncService: AgentHostCatalogSyncService; private readonly _catalogSourceResolver: AgentHostCatalogSourceResolver; private readonly _peerChatStore: AgentHostPeerChatStore; private readonly _catalogReconciliationService: AgentHostCatalogReconciliationService; - private readonly _catalogShadowValidator: AgentHostCatalogShadowValidator; private readonly _catalogListReader: AgentHostCatalogListReader; private readonly _sessionsV2MigrationService: AgentHostSessionsV2MigrationService; private readonly _catalogListRepair = this._register(new MutableDisposable()); @@ -646,7 +631,6 @@ export class AgentService extends Disposable implements IAgentService { this._sideEffects = collaborators.sideEffects; this._sessionCoordination = collaborators.sessionCoordination; this._serverToolHost = collaborators.serverToolHost; - this._catalogReadMode = core.catalogReadMode ?? 'legacy'; this._catalogSyncService = new AgentHostCatalogSyncService(this._sessionDataService, this._orchestratorDatabase, this._logService); this._catalogSourceResolver = new AgentHostCatalogSourceResolver({ openDatabase: session => this._sessionDataService.openDatabase(session), @@ -756,13 +740,6 @@ export class AgentService extends Disposable implements IAgentService { this._logService, )); this._catalogReconciliationService.schedule(); - this._catalogShadowValidator = new AgentHostCatalogShadowValidator( - this._orchestratorDatabase, - core.catalogShadowReporter ?? { report: () => { } }, - () => this._catalogReconciliationService.schedule(), - this._logService, - core.catalogShadowConcurrency === undefined ? {} : { concurrency: core.catalogShadowConcurrency }, - ); this._register(core.disposables); } @@ -2024,13 +2001,13 @@ export class AgentService extends Disposable implements IAgentService { candidate => isSubagentSession(candidate.session.toString()) ? { reason: 'subagent', fingerprint: 'uri-v1' } : candidate.catalog?.isChatBacking === true - ? { reason: 'backing', fingerprint: candidate.catalog.sourceHash } + ? { reason: 'backing', fingerprint: candidate.catalog.payloadHash } : undefined, candidate => this._resolveSessionsV2ImportCandidate(provider, candidate), force, ); if (!report) { - if (!await this._sessionRegistry.isSessionsV2Backfilled(provider.id, AGENT_HOST_CATALOG_PROJECTION_VERSION)) { + if (!await this._sessionRegistry.isSessionsV2Backfilled(provider.id, AGENT_HOST_CATALOG_PAYLOAD_VERSION)) { throw new ProviderCatalogUnavailableError(provider.id); } return; @@ -2317,23 +2294,18 @@ export class AgentService extends Disposable implements IAgentService { return undefined; } - if (this._catalogReadMode === 'centralWithFallback' || this._catalogReadMode === 'central') { - const central = await this._catalogListReader.read(registeredSession); - if (central.eligible) { - return central.metadata; - } - if (central.reason === 'chatBacking') { - return undefined; - } - repairNeeded = true; - if (central.reason === 'readError') { - 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.reason}`); - } - if (this._catalogReadMode === 'central') { - return undefined; - } + const central = await this._catalogListReader.read(registeredSession); + if (central.eligible) { + return central.metadata; + } + if (central.chatBacking) { + return undefined; + } + repairNeeded = true; + 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 { @@ -2343,7 +2315,10 @@ export class AgentService extends Disposable implements IAgentService { return undefined; } }))); - if (repairNeeded) { + // 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 (repairNeeded && !this._store.isDisposed) { this._catalogListRepair.value = disposableTimeout(() => { this._catalogListRepair.clear(); this._catalogReconciliationService.start(); @@ -2448,9 +2423,6 @@ export class AgentService extends Disposable implements IAgentService { if (epoch !== this._registryEpoch) { return this.listSessions(mode); } - if (this._catalogReadMode === 'shadow') { - this._catalogShadowValidator.schedule(visible, registered); - } return visible; } @@ -3186,15 +3158,22 @@ export class AgentService extends Disposable implements IAgentService { ? { ...existing, title: sessionState.title } : existing )); - const catalogChats = [ - ...existingCatalogChats, - { - uri: chat.toString(), - kind: 'peer' as const, - ...(title !== undefined ? { title } : {}), - ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), - }, - ]; + const newCatalogChat = { + uri: chat.toString(), + kind: 'peer' as const, + ...(title !== undefined ? { title } : {}), + ...(peerChatOrigin !== undefined ? { origin: peerChatOrigin } : {}), + }; + // Re-creating an existing chat must not add a second membership entry: + // chat URIs are unique in the catalog. Merge into the existing entry in + // place so repeated `createChat` calls stay idempotent while preserving + // catalog order and the entry's kind (a default chat stays default). + 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); @@ -5748,17 +5727,19 @@ export class AgentService extends Disposable implements IAgentService { } const result = await this._catalogListReader.read(registered); if (!result.eligible) { - if (result.reason === 'readError') { + 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.reason}`); + this._logService.trace(`[AgentService] Central chat catalog for ${session.toString()} is ineligible: ${result.detail}`); } return undefined; } - return result.source.chats.map(chat => ({ - uri: chat.uri, + return result.data.chats.map(chat => ({ + uri: chat.uri.toString(), kind: chat.kind, - title: chat.title, + title: chat.summary, origin: fromCatalogChatOrigin(chat.origin), })); } diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index 1fe92003b0025b..63f0ba7e4fa3a1 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -95,9 +95,6 @@ export function createAgentServiceComposition( configurationService, agents, callbackBinder: callbackAdapter, - catalogReadMode: options.catalogReadMode, - catalogShadowReporter: options.catalogShadowReporter, - catalogShadowConcurrency: options.catalogShadowConcurrency, }; // AgentService subscribes after this graph is complete, so collaborator constructors must not emit state-manager events. const octoKitService = accessor.get(IAgentHostOctoKitService); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts index 3ffc53982a16c8..1a925239b6074a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts @@ -6,10 +6,10 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agent.js'; -import { readSessionArtifacts } from '../../common/sessionArtifacts.js'; -import { isSessionStatusArchived, isSessionStatusRead, readSessionEhcliAdoptable, readSessionExternal, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionOrchestration, readSessionSourceControlState, readSessionWorkspaceless } from '../../common/state/sessionState.js'; +import { readSessionArtifacts, SESSION_META_ARTIFACTS_KEY } from '../../common/sessionArtifacts.js'; +import { isSessionStatusArchived, isSessionStatusRead, readSessionEhcliAdoptable, readSessionExternal, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionOrchestration, readSessionSourceControlState, readSessionWorkspaceless, 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_ORCHESTRATION_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_PROJECTION_VERSION, projectAgentHostCatalog, type IAgentHostCatalogSource } from '../../node/agentHostCatalogProjection.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'; @@ -39,61 +39,71 @@ suite('AgentHostCatalogListReader', () => { external: true, source: 'discovery', }; - const source: IAgentHostCatalogSource = { + const data: AgentHostCatalogData = { modifiedTime: 200, - title: 'Catalog title', + summary: 'Catalog title', titleSource: 'user', isRead: true, isArchived: true, project: { uri: 'file:///workspace', displayName: 'Workspace' }, - workspaceless: true, - ehcliAdoptable: true, - multiRoot: { workspaceFile: 'file:///workspace/project.code-workspace' }, - folderPicker: { hidden: true, primary: 'file:///workspace' }, + workingDirectories: ['file:///workspace', 'file:///other'], changes: { additions: 4, deletions: 2, files: 3 }, - github: { owner: 'microsoft', repo: 'vscode', pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'] }, - git: { - hasGitHubRemote: true, - branchName: 'feature', - baseBranchName: 'main', - upstreamBranchName: 'origin/feature', - incomingChanges: 1, - outgoingChanges: 2, - uncommittedChanges: 3, - hasBaseBranchChanges: true, - githubOwner: 'microsoft', - githubHeadOwner: 'contributor', - githubRepo: 'vscode', - }, - sourceControl: { merge: { commit: 'abc123' }, latestOutcome: 'pullRequest' }, - artifacts: [{ id: 'artifact', type: 'pullRequest', label: 'PR', link: 'https://github.com/microsoft/vscode/pull/1', isGitHub: true, createdByThisSession: true }], - orchestration: { - parentSession: 'agent-session://copilot/parent', - creatorSession: 'agent-session://copilot/creator', - label: 'child', - coordinateWithCreator: true, - notifyOnIdle: 'always', - creatorNotificationState: 'waitingForCompletion', + _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', link: 'https://github.com/microsoft/vscode/pull/1', isGitHub: true, createdByThisSession: true }], + [SESSION_META_ORCHESTRATION_KEY]: { + parentSession: 'agent-session://copilot/parent', + creatorSession: 'agent-session://copilot/creator', + label: 'child', + coordinateWithCreator: true, + notifyOnIdle: 'always', + creatorNotificationState: 'waitingForCompletion', + }, + [SESSION_META_WORKSPACELESS_KEY]: true, + [SESSION_META_EHCLI_ADOPTABLE_KEY]: true, }, - workingDirectories: ['file:///workspace', 'file:///other'], chats: [ - { uri: `${session.toString()}/chat/default`, order: 0, kind: 'default', title: 'Catalog title', titleSource: 'user' }, - { uri: `${session.toString()}/chat/peer`, order: 1, kind: 'peer', title: 'Peer title', titleSource: 'agent', origin: { kind: 'fork', chat: `${session.toString()}/chat/default`, turnId: 'turn-1' } }, + { 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 createDatabase(): TestCatalogDatabase { + 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 projection = projectAgentHostCatalog(source, { + const encoded = encode(catalogData); + database.catalog = { session: session.toString(), sessionGeneration: 'incarnation', sourceRevision: 2, - }); - if (!projection.ok) { - throw new Error(projection.error.message); - } - database.catalog = { - ...projection.value.catalog, + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, + payloadHash: encoded.payloadHash, + verified: true, + payload: encoded.payload, + isChatBacking: catalogData.isChatBacking === true, provider: registered.provider, startTime: registered.startTime, external: registered.external, @@ -102,7 +112,7 @@ suite('AgentHostCatalogListReader', () => { return database; } - test('converts a verified projection-v3 catalog into complete list metadata', async () => { + 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) { @@ -129,7 +139,7 @@ suite('AgentHostCatalogListReader', () => { sourceControl: readSessionSourceControlState(result.metadata._meta), artifacts: readSessionArtifacts(result.metadata._meta), orchestration: readSessionOrchestration(result.metadata._meta), - chats: result.source.chats, + chats: result.data.chats.map(chat => ({ ...chat, uri: chat.uri.toString() })), }, { session: session.toString(), startTime: 100, @@ -139,43 +149,64 @@ suite('AgentHostCatalogListReader', () => { isArchived: true, project: { uri: 'file:///workspace', displayName: 'Workspace' }, workingDirectories: ['file:///workspace', 'file:///other'], - changes: source.changes, + changes: data.changes, external: true, workspaceless: true, ehcliAdoptable: true, - multiRoot: source.multiRoot, - folderPicker: source.folderPicker, - github: source.github, - git: source.git, - sourceControl: source.sourceControl, - artifacts: source.artifacts, - orchestration: source.orchestration, - chats: source.chats.map(chat => ({ ...chat, origin: chat.origin })), + 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], + orchestration: data._meta?.[SESSION_META_ORCHESTRATION_KEY], + chats: data.chats, }); }); - test('returns explicit ineligibility reasons without fabricating metadata', async () => { + 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: 'missingCatalog', mutate: database => database.catalog = undefined }, + { expected: 'fallback', mutate: database => database.catalog = undefined }, { expected: 'chatBacking', mutate: database => database.catalog = { ...database.catalog!, isChatBacking: true } }, - { expected: 'identityMismatch', mutate: database => database.catalog = { ...database.catalog!, session: AgentSession.uri('copilot', 'other').toString() } }, - { expected: 'providerMismatch', mutate: database => database.catalog = { ...database.catalog!, provider: 'claude' } }, - { expected: 'outdated', mutate: database => database.catalog = { ...database.catalog!, projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION - 1 } }, - { expected: 'malformed', mutate: database => database.catalog = { ...database.catalog!, sourceHash: 'not-the-canonical-hash' } }, - { expected: 'readError', mutate: database => database.readError = new Error('read failed') }, + { 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.reason); + 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.deepStrictEqual(result, { eligible: false, reason: 'providerMismatch' }); + + 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 index 27a6c069b14606..0eb2973891c836 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts @@ -4,29 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { createHash } from 'crypto'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { SESSION_META_ARTIFACTS_KEY } from '../../common/sessionArtifacts.js'; +import { 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_ORCHESTRATION_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY } from '../../common/state/sessionState.js'; import { AGENT_HOST_CATALOG_ARTIFACT_LIMIT, - AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT, - AGENT_HOST_CATALOG_PROJECTION_VERSION, - IAgentHostCatalogProjection, - IAgentHostCatalogSource, - parseAgentHostCatalogSourcePayload, - parseAgentHostDatabaseCatalog, - projectAgentHostCatalog, + AGENT_HOST_CATALOG_CHILD_LIMIT, + AGENT_HOST_CATALOG_PAYLOAD_VERSION, + AgentHostCatalogData, + decodeAgentHostCatalogPayload, + encodeAgentHostCatalogPayload, + hashAgentHostCatalogPayload, + reviveAgentHostCatalogData, } from '../../node/agentHostCatalogProjection.js'; -const options = { - session: 'agent-session://test/session', - sessionGeneration: 'incarnation-1', - sourceRevision: 7, -} as const; - -function createSource(): IAgentHostCatalogSource { +function createData(): AgentHostCatalogData { return { modifiedTime: 1720000000000, - title: 'Implement catalog projection', + summary: 'Implement opaque catalog payload', titleSource: 'user', isRead: true, isArchived: false, @@ -34,449 +29,203 @@ function createSource(): IAgentHostCatalogSource { uri: 'file:///workspace', displayName: 'workspace', }, - workspaceless: false, - ehcliAdoptable: true, - ehcliAdopted: true, - multiRoot: { - workspaceFile: 'file:///workspace/project.code-workspace', - }, - folderPicker: { - hidden: true, - primary: 'file:///workspace', - }, + isChatBacking: false, + workingDirectories: ['file:///workspace', 'file:///workspace/secondary'], changes: { additions: 12, deletions: 4, files: 2, }, - github: { - owner: 'microsoft', - repo: 'vscode', - pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], - initialPullRequestUrls: [], - associatedPullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'], - issueUrls: ['https://github.com/microsoft/vscode/issues/2'], - pullRequestBranchName: 'catalog-projection', - }, - git: { - hasGitHubRemote: true, - branchName: 'feature/catalog', - baseBranchName: 'main', - upstreamBranchName: 'origin/feature/catalog', - incomingChanges: 2, - outgoingChanges: 3, - uncommittedChanges: 4, - hasBaseBranchChanges: true, - githubOwner: 'microsoft', - githubHeadOwner: 'contributor', - githubRepo: 'vscode', - }, - sourceControl: { - merge: { commit: '0123456789abcdef' }, - latestOutcome: 'merge', - }, - artifacts: [{ - id: 'artifact-1', - type: 'pullRequest', - label: 'Catalog projection', - link: 'https://github.com/microsoft/vscode/pull/1', - isGitHub: true, - createdByThisSession: true, - }], - orchestration: { - parentSession: 'agent-session://test/parent', - creatorSession: 'agent-session://test/parent', - label: 'projection', - coordinateWithCreator: true, - notifyOnIdle: 'once', - creatorNotificationState: 'waitingForCompletion', + _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', + link: 'https://github.com/microsoft/vscode/pull/1', + createdByThisSession: true, + }], + [SESSION_META_ORCHESTRATION_KEY]: { + parentSession: 'agent-session://test/parent', + creatorSession: 'agent-session://test/parent', + coordinateWithCreator: true, + notifyOnIdle: 'once', + }, + [SESSION_META_WORKSPACELESS_KEY]: true, + [SESSION_META_EHCLI_ADOPTABLE_KEY]: true, + [SESSION_META_EHCLI_ADOPTED_KEY]: true, }, - workingDirectories: ['file:///workspace', 'file:///workspace/secondary'], chats: [{ uri: 'agent-chat://test/session/default', order: 0, kind: 'default', - title: 'Main', + summary: 'Main', titleSource: 'auto', origin: { kind: 'default', metadata: { b: 2, a: 1 } }, }, { uri: 'agent-chat://test/session/peer', order: 1, kind: 'peer', - title: 'Peer', + summary: 'Peer', titleSource: 'agent', origin: { kind: 'subagent' }, }], }; } -function project(source: IAgentHostCatalogSource = createSource()): IAgentHostCatalogProjection { - const result = projectAgentHostCatalog(source, options); +function encode(data: AgentHostCatalogData = createData()) { + const result = encodeAgentHostCatalogPayload(data); assert.strictEqual(result.ok, true); return result.value; } -function errorField(result: ReturnType | ReturnType): string | undefined { - return result.ok ? undefined : result.error.field; -} - suite('AgentHostCatalogProjection', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('is deterministic across property and chat insertion order', () => { - const source = createSource(); - const reordered: IAgentHostCatalogSource = { - chats: [{ - origin: { kind: 'subagent' }, - titleSource: 'agent', - title: 'Peer', - kind: 'peer', - order: 1, - uri: 'agent-chat://test/session/peer', - }, { - origin: { metadata: { a: 1, b: 2 }, kind: 'default' }, - titleSource: 'auto', - title: 'Main', - kind: 'default', - order: 0, - uri: 'agent-chat://test/session/default', - }], - workingDirectories: source.workingDirectories, - orchestration: { - creatorNotificationState: 'waitingForCompletion', - notifyOnIdle: 'once', - coordinateWithCreator: true, - label: 'projection', - creatorSession: 'agent-session://test/parent', - parentSession: 'agent-session://test/parent', - }, - artifacts: source.artifacts, - sourceControl: { latestOutcome: 'merge', merge: { commit: '0123456789abcdef' } }, - git: { - githubRepo: 'vscode', - githubHeadOwner: 'contributor', - githubOwner: 'microsoft', - hasBaseBranchChanges: true, - uncommittedChanges: 4, - outgoingChanges: 3, - incomingChanges: 2, - upstreamBranchName: 'origin/feature/catalog', - baseBranchName: 'main', - branchName: 'feature/catalog', - hasGitHubRemote: true, - }, - github: source.github, - changes: { files: 2, deletions: 4, additions: 12 }, - folderPicker: { primary: 'file:///workspace', hidden: true }, - multiRoot: source.multiRoot, - ehcliAdoptable: true, - ehcliAdopted: true, - workspaceless: false, - project: { displayName: 'workspace', uri: 'file:///workspace' }, - isArchived: false, - isRead: true, - titleSource: 'user', - title: 'Implement catalog projection', - modifiedTime: 1720000000000, - }; + 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); - const first = project(source); - const second = project(reordered); assert.deepStrictEqual({ - payloadEqual: first.sourcePayload === second.sourcePayload, - hashEqual: first.catalog.sourceHash === second.catalog.sourceHash, - workingDirectoriesJson: second.catalog.workingDirectoriesJson, - chatOrder: (JSON.parse(second.catalog.chatsJson) as Array<{ uri: string }>).map(chat => chat.uri), + decoded, + payload: encoded.payload, + hash: encoded.payloadHash, }, { - payloadEqual: true, - hashEqual: true, - workingDirectoriesJson: '["file:///workspace","file:///workspace/secondary"]', - chatOrder: [ - 'agent-chat://test/session/default', - 'agent-chat://test/session/peer', - ], - }); - }); - - test('round trips every list-visible Git field and preserves absent versus zero and false', () => { - const projection = project(); - const parsedPayload = parseAgentHostCatalogSourcePayload(projection.sourcePayload); - const parsedCatalog = parseAgentHostDatabaseCatalog(projection.catalog); - const sparse = project({ - ...createSource(), - git: { - hasGitHubRemote: false, - incomingChanges: 0, + decoded: { + ok: true, + value: { data: typedData, payload: encoded.payload }, }, - }); - assert.strictEqual(parsedPayload.ok, true); - assert.strictEqual(parsedCatalog.ok, true); - - assert.deepStrictEqual({ - projectedGit: projection.source.git, - catalogGit: projection.catalog.gitSummaryJson, - payloadGit: parsedPayload.value.source.git, - parsedCatalogGit: parsedCatalog.value.source.git, - sparseSourceGit: sparse.source.git, - sparseCatalogGit: sparse.catalog.gitSummaryJson, - }, { - projectedGit: createSource().git, - catalogGit: '{"baseBranchName":"main","branchName":"feature/catalog","githubHeadOwner":"contributor","githubOwner":"microsoft","githubRepo":"vscode","hasBaseBranchChanges":true,"hasGitHubRemote":true,"incomingChanges":2,"outgoingChanges":3,"uncommittedChanges":4,"upstreamBranchName":"origin/feature/catalog"}', - payloadGit: createSource().git, - parsedCatalogGit: createSource().git, - sparseSourceGit: { hasGitHubRemote: false, incomingChanges: 0 }, - sparseCatalogGit: '{"hasGitHubRemote":false,"incomingChanges":0}', + payload: '{"data":{"_meta":{"agentHost/orchestration":{"coordinateWithCreator":true,"creatorSession":"agent-session://test/parent","notifyOnIdle":"once","parentSession":"agent-session://test/parent"},"agentHost/sessionArtifacts":[{"createdByThisSession":true,"id":"artifact-1","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('changes the canonical hash for every meaningful Git field', () => { - const source = createSource(); - const baselineHash = project(source).catalog.sourceHash; - const git = source.git!; - const hashes = [ - project({ ...source, git: { ...git, hasGitHubRemote: false } }).catalog.sourceHash, - project({ ...source, git: { ...git, branchName: 'feature/other' } }).catalog.sourceHash, - project({ ...source, git: { ...git, baseBranchName: 'develop' } }).catalog.sourceHash, - project({ ...source, git: { ...git, upstreamBranchName: 'origin/feature/other' } }).catalog.sourceHash, - project({ ...source, git: { ...git, incomingChanges: 5 } }).catalog.sourceHash, - project({ ...source, git: { ...git, outgoingChanges: 6 } }).catalog.sourceHash, - project({ ...source, git: { ...git, uncommittedChanges: 7 } }).catalog.sourceHash, - project({ ...source, git: { ...git, hasBaseBranchChanges: false } }).catalog.sourceHash, - project({ ...source, git: { ...git, githubOwner: 'owner' } }).catalog.sourceHash, - project({ ...source, git: { ...git, githubHeadOwner: 'head-owner' } }).catalog.sourceHash, - project({ ...source, git: { ...git, githubRepo: 'repository' } }).catalog.sourceHash, - ]; + 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(); - assert.deepStrictEqual(hashes.map(hash => hash !== baselineHash), Array.from({ length: hashes.length }, () => true)); - }); - - test('rejects invalid Git counts, oversized strings, and unknown fields', () => { - const source = createSource(); - const negative = projectAgentHostCatalog({ ...source, git: { incomingChanges: -1 } }, options); - const unsafe = projectAgentHostCatalog({ ...source, git: { outgoingChanges: Number.MAX_SAFE_INTEGER + 1 } }, options); - const oversized = projectAgentHostCatalog({ ...source, git: { branchName: 'b'.repeat(1025) } }, options); - const unknown = projectAgentHostCatalog({ - ...source, - git: { branchName: 'main', rawPath: '/private/repository' }, - } as IAgentHostCatalogSource, options); - - assert.deepStrictEqual([ - errorField(negative), - errorField(unsafe), - errorField(oversized), - errorField(unknown), - ], [ - 'git.incomingChanges', - 'git.outgoingChanges', - 'git.branchName', - 'git.rawPath', - ]); - }); - - test('changes hash for meaningful list-visible fields but excludes hydrate-on-open state', () => { - const source = createSource(); - const baseline = project(source); - const changed = project({ ...source, isArchived: true }); - const payload = JSON.parse(baseline.sourcePayload) as Record; - - assert.deepStrictEqual({ - hashChanged: baseline.catalog.sourceHash !== changed.catalog.sourceHash, - excludedFields: [ - 'turns', 'drafts', 'annotations', 'providerData', 'configuration', - 'resumeData', 'changesets', 'activity', 'status', - ].filter(field => JSON.stringify(payload).includes(field)), - }, { - hashChanged: true, - excludedFields: [], - }); - }); - - test('canonically hashes, validates, and round trips the adoptable marker', () => { - const adoptable = project(); - const adopted = project({ ...createSource(), ehcliAdoptable: false }); - const missing = JSON.stringify({ - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - source: { - ...(JSON.parse(adoptable.sourcePayload) as { source: Record }).source, - ehcliAdoptable: undefined, - }, - }); - const invalid = JSON.stringify({ - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - source: { - ...(JSON.parse(adoptable.sourcePayload) as { source: Record }).source, - ehcliAdoptable: 'true', - }, - }); - const roundTrip = parseAgentHostDatabaseCatalog(adoptable.catalog); - const missingPayload = parseAgentHostCatalogSourcePayload(missing); - const invalidPayload = parseAgentHostCatalogSourcePayload(invalid); + const decoded = decodeAgentHostCatalogPayload(JSON.stringify(source)); + assert.strictEqual(decoded.ok, true); assert.deepStrictEqual({ - hashChanged: adoptable.catalog.sourceHash !== adopted.catalog.sourceHash, - catalogMarker: adoptable.catalog.ehcliAdoptable, - payloadMarker: (JSON.parse(adoptable.sourcePayload) as { source: { ehcliAdoptable: boolean } }).source.ehcliAdoptable, - roundTripMarker: roundTrip.ok ? roundTrip.value.source.ehcliAdoptable : undefined, - missingPayloadError: missingPayload.ok ? undefined : missingPayload.error.field, - invalidSourceError: invalidPayload.ok ? undefined : invalidPayload.error.field, + 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), }, { - hashChanged: true, - catalogMarker: true, - payloadMarker: true, - roundTripMarker: true, - missingPayloadError: 'sourcePayload', - invalidSourceError: 'ehcliAdoptable', + hasFutureEnvelopeField: false, + hasFuturePayloadField: false, + hasFutureProjectField: false, + hasFutureMetaKey: false, + chatOrder: [0, 1], }); - }); - test('bounds and de-duplicates each GitHub reference history', () => { - const source = createSource(); - const references = Array.from({ length: AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT + 5 }, (_, index) => `https://github.com/microsoft/vscode/issues/${index}`); - const projection = project({ - ...source, - github: { - ...source.github, - issueUrls: [references[0].toUpperCase(), ...references], - }, - }); - - assert.deepStrictEqual(projection.source.github?.issueUrls, [ - references[0].toUpperCase(), - ...references.slice(1, AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT), - ]); - }); - - test('retains the most recent artifact suffix and round trips it', () => { - const source = createSource(); - const artifacts = Array.from({ length: AGENT_HOST_CATALOG_ARTIFACT_LIMIT + 5 }, (_, index) => ({ - id: `artifact-${index}`, - type: 'resource' as const, - label: `Artifact ${index}`, - uri: `file:///artifact-${index}`, - })); - const projection = project({ ...source, artifacts }); - const parsed = parseAgentHostDatabaseCatalog(projection.catalog); - assert.strictEqual(parsed.ok, true); - - const expectedIds = artifacts.slice(-AGENT_HOST_CATALOG_ARTIFACT_LIMIT).map(artifact => artifact.id); - assert.deepStrictEqual({ - projectedIds: projection.source.artifacts?.map(artifact => artifact.id), - parsedIds: parsed.value.source.artifacts?.map(artifact => artifact.id), - }, { - projectedIds: expectedIds, - parsedIds: expectedIds, + 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('sorts chats and rejects duplicate or non-contiguous exact-set children', () => { - const source = createSource(); - const duplicateDirectory = projectAgentHostCatalog({ - ...source, - workingDirectories: ['file:///workspace', 'file:///workspace'], - }, options); - const duplicateChatUri = projectAgentHostCatalog({ - ...source, - chats: [source.chats[0], { ...source.chats[1], uri: source.chats[0].uri }], - }, options); - const duplicateChatOrder = projectAgentHostCatalog({ - ...source, - chats: [source.chats[0], { ...source.chats[1], order: 0 }], - }, options); - const sparseChatOrder = projectAgentHostCatalog({ - ...source, - chats: [{ ...source.chats[0], order: 1 }, { ...source.chats[1], order: 2 }], - }, options); + 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([ - errorField(duplicateDirectory), - errorField(duplicateChatUri), - errorField(duplicateChatOrder), - errorField(sparseChatOrder), - ], [ - 'workingDirectories[1]', - 'chats[1].uri', - 'chats[1].order', - 'chats[0].order', + 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('returns typed failures for malformed or noncanonical structured catalog data', () => { - const catalog = project().catalog; - const malformed = parseAgentHostDatabaseCatalog({ ...catalog, githubSummaryJson: '{' }); - const noncanonical = parseAgentHostDatabaseCatalog({ ...catalog, changesSummaryJson: '{"files":2,"additions":12,"deletions":4}' }); - const tamperedOrigin = parseAgentHostDatabaseCatalog({ - ...catalog, - chatsJson: catalog.chatsJson.replace('{\\"kind\\":\\"default\\",\\"metadata\\":{\\"a\\":1,\\"b\\":2}}', '{\\"kind\\":\\"tampered\\"}'), - }); + 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 = {}; - assert.deepStrictEqual([ - errorField(malformed), - errorField(noncanonical), - errorField(tamperedOrigin), - ], [ - 'githubSummaryJson', - 'changesSummaryJson', - 'sourceHash', - ]); - }); - - test('includes projection version in the hashed canonical payload', () => { - const projection = project(); - const payload = JSON.parse(projection.sourcePayload) as { projectionVersion: number; source: unknown }; - const nextVersionPayload = JSON.stringify({ - projectionVersion: payload.projectionVersion + 1, - source: payload.source, - }); - const nextVersionHash = createHash('sha256').update(nextVersionPayload, 'utf8').digest('hex'); + const decoded = decodeAgentHostCatalogPayload(JSON.stringify(payload)); - assert.deepStrictEqual({ - projectionVersion: payload.projectionVersion, - hashMatchesPayload: projection.catalog.sourceHash === createHash('sha256').update(projection.sourcePayload, 'utf8').digest('hex'), - versionChangesHash: projection.catalog.sourceHash !== nextVersionHash, - }, { - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - hashMatchesPayload: true, - versionChangesHash: true, - }); - }); - - test('identifies projection-v2 source payloads and catalogs as outdated', () => { - const projection = project(); - const parsedPayload = JSON.parse(projection.sourcePayload) as { projectionVersion: number; source: unknown }; - const oldPayload = JSON.stringify({ projectionVersion: 2, source: parsedPayload.source }); - const payloadResult = parseAgentHostCatalogSourcePayload(oldPayload); - const catalogResult = parseAgentHostDatabaseCatalog({ - ...projection.catalog, - projectionVersion: 2, - ehcliAdoptable: undefined, - }); - - assert.deepStrictEqual({ - currentVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - payloadError: payloadResult.ok ? undefined : payloadResult.error, - catalogError: catalogResult.ok ? undefined : catalogResult.error, - }, { - currentVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, - payloadError: { field: 'sourcePayload.projectionVersion', message: `Expected projection version ${AGENT_HOST_CATALOG_PROJECTION_VERSION}.` }, - catalogError: { field: 'projectionVersion', message: `Expected projection version ${AGENT_HOST_CATALOG_PROJECTION_VERSION}.` }, - }); + assert.deepStrictEqual(decoded.ok ? 'ok' : decoded.reason, 'outdated'); }); - test('round trips source payload and central catalog type', () => { - const projection = project(); - const parsedPayload = parseAgentHostCatalogSourcePayload(projection.sourcePayload); - const parsedCatalog = parseAgentHostDatabaseCatalog(projection.catalog); - assert.strictEqual(parsedPayload.ok, true); - assert.strictEqual(parsedCatalog.ok, true); + test('revives every serialized URI in one place', () => { + const data = createData(); + const revived = reviveAgentHostCatalogData(data); assert.deepStrictEqual({ - payloadSource: parsedPayload.value.source, - catalogSource: parsedCatalog.value.source, - catalog: parsedCatalog.value.catalog, + project: revived.project?.uri.toString(), + workingDirectories: revived.workingDirectories.map(uri => uri.toString()), + chats: revived.chats.map(chat => chat.uri.toString()), }, { - payloadSource: projection.source, - catalogSource: projection.source, - catalog: projection.catalog, + 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 index b3b2491768e57c..422c6ace5e1e67 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts @@ -13,30 +13,35 @@ import { NullLogService } from '../../../log/common/log.js'; import type { ISessionDataService } from '../../common/sessionDataService.js'; import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult } from '../../node/agentHostCatalogReconciliationService.js'; import { AgentHostCatalogSyncService } from '../../node/agentHostCatalogSyncService.js'; -import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; +import { AgentHostCatalogData, AGENT_HOST_CATALOG_PAYLOAD_VERSION } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, 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 catalogSource(title: string) { +function catalogData(summary: string): AgentHostCatalogData { return { modifiedTime: 1, - title, - titleSource: 'user' as const, + summary, + titleSource: 'user', isRead: false, isArchived: false, - workspaceless: true, workingDirectories: [], chats: [{ - uri: `agenthost-chat:${title}/default`, + uri: `agenthost-chat:${summary}/default`, order: 0, - kind: 'default' as const, - title, - titleSource: 'user' as const, + 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}`), @@ -75,12 +80,12 @@ class RecordingCatalogDatabase extends AgentHostDatabase { super(':memory:'); } - override async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { + override async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { this.upsertCalls++; if (this.failUpsert) { throw new Error('central unavailable'); } - return super.upsertSessionV2(projection, expectedSessionGeneration); + return super.upsertSessionV2(envelope, expectedSessionGeneration); } } @@ -132,7 +137,7 @@ suite('AgentHostCatalogReconciliationService', () => { sync, createService: (resolveSource = async session => ({ status: 'available', - request: { source: catalogSource(session.session.path), legacyMetadata: { customTitle: session.session.path } }, + request: { data: catalogData(session.session.path), legacyMetadata: { customTitle: session.session.path } }, })) => store.add(new AgentHostCatalogReconciliationService( sessionDataService, central, @@ -148,7 +153,7 @@ suite('AgentHostCatalogReconciliationService', () => { test('skips only an exact sessions_v2 row, compact receipt, and canonical legacy match', async () => { const harness = await createHarness(['one']); const session = registered('one'); - await harness.sync.synchronize(session.session, { source: catalogSource('one'), legacyMetadata: { customTitle: 'one' } }); + await harness.sync.synchronize(session.session, { data: catalogData('one'), legacyMetadata: { customTitle: 'one' } }); harness.central.upsertCalls = 0; const service = harness.createService(); @@ -170,7 +175,7 @@ suite('AgentHostCatalogReconciliationService', () => { const harness = await createHarness(['one']); const session = registered('one'); harness.central.failUpsert = true; - await harness.sync.synchronize(session.session, { source: catalogSource('one'), legacyMetadata: { customTitle: 'one' } }); + 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; @@ -181,7 +186,7 @@ suite('AgentHostCatalogReconciliationService', () => { before: { state: pending?.state, hasPayload: pending?.payload !== undefined }, outcomes: report.outcomes, after: { state: acknowledged?.state, payload: acknowledged?.payload }, - catalogTitle: (await harness.central.getSessionV2(session.session.toString()))?.title, + 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 }], @@ -193,18 +198,18 @@ suite('AgentHostCatalogReconciliationService', () => { 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, { source: catalogSource('one'), legacyMetadata: { customTitle: '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: { source: catalogSource('old-title'), legacyMetadata: { customTitle: 'old-title' } }, + 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: (await harness.central.getSessionV2(session.session.toString()))?.title, + title: summaryOf((await harness.central.getSessionV2(session.session.toString()))!.payload), revision: receipt?.sourceRevision, payload: receipt?.payload, }, { @@ -215,10 +220,49 @@ suite('AgentHostCatalogReconciliationService', () => { }); }); + 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, { source: catalogSource('one'), legacyMetadata: { customTitle: '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); @@ -226,7 +270,7 @@ suite('AgentHostCatalogReconciliationService', () => { const report = await harness.createService(async () => ({ status: 'available', - request: { source: catalogSource('two'), legacyMetadata: { customTitle: 'two' } }, + request: { data: catalogData('two'), legacyMetadata: { customTitle: 'two' } }, })).runPass(); assert.deepStrictEqual({ diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogShadowValidator.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogShadowValidator.test.ts deleted file mode 100644 index f334d29aad85f1..00000000000000 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogShadowValidator.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { DeferredPromise, timeout } from '../../../../base/common/async.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { AgentSession, type IAgentSessionMetadata } from '../../common/agent.js'; -import { SessionArtifactType, withSessionArtifacts } from '../../common/sessionArtifacts.js'; -import { SessionSourceControlOutcome, SessionStatus, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, withSessionSourceControlState, withSessionWorkspaceless, type SessionMeta } from '../../common/state/sessionState.js'; -import { projectAgentHostCatalog, type IAgentHostCatalogSource } from '../../node/agentHostCatalogProjection.js'; -import { AgentHostCatalogShadowValidator, type IAgentHostCatalogShadowValidationReport, type IAgentHostCatalogShadowValidationReporter } from '../../node/agentHostCatalogShadowValidator.js'; -import { AgentHostDatabase, type IAgentHostDatabaseSessionV2 } from '../../node/agentHostDatabase.js'; -import type { IRegisteredSession } from '../../node/agentSessionRegistry.js'; - -class RecordingReporter implements IAgentHostCatalogShadowValidationReporter { - readonly reports: IAgentHostCatalogShadowValidationReport[] = []; - - report(report: IAgentHostCatalogShadowValidationReport): void { - this.reports.push(report); - } -} - -class ShadowCatalogDatabase extends AgentHostDatabase { - readonly catalogs = new Map(); - activeReads = 0; - maxConcurrentActiveReads = 0; - activeReadDelay = 0; - - constructor() { - super(':memory:'); - } - - override async getSessionV2(session: string): Promise { - this.activeReads++; - this.maxConcurrentActiveReads = Math.max(this.maxConcurrentActiveReads, this.activeReads); - try { - if (this.activeReadDelay > 0) { - await timeout(this.activeReadDelay); - } - const value = this.catalogs.get(session); - if (value instanceof Error) { - throw value; - } - return value; - } finally { - this.activeReads--; - } - } -} - -suite('AgentHostCatalogShadowValidator', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - function source(overrides: Partial = {}): IAgentHostCatalogSource { - return { - modifiedTime: 2, - title: 'Session', - titleSource: 'user', - isRead: true, - isArchived: true, - project: { uri: 'file:///project', displayName: 'Project' }, - workspaceless: true, - ehcliAdoptable: true, - multiRoot: { workspaceFile: 'file:///workspace.code-workspace' }, - folderPicker: { hidden: true, primary: 'file:///project' }, - changes: { additions: 1, deletions: 2, files: 3 }, - github: { owner: 'owner', repo: 'repo', pullRequestUrls: ['https://example.invalid/pr/1'] }, - git: { - hasGitHubRemote: true, - branchName: 'feature', - baseBranchName: 'main', - upstreamBranchName: 'origin/feature', - incomingChanges: 1, - outgoingChanges: 2, - uncommittedChanges: 3, - hasBaseBranchChanges: true, - githubOwner: 'owner', - githubHeadOwner: 'contributor', - githubRepo: 'repo', - }, - sourceControl: { merge: { commit: 'abc' }, latestOutcome: 'merge' }, - artifacts: [{ id: 'artifact', type: 'file', label: 'Artifact', uri: 'file:///artifact' }], - orchestration: { - parentSession: 'copilot:/parent', - creatorSession: 'copilot:/creator', - coordinateWithCreator: true, - notifyOnIdle: 'once', - }, - workingDirectories: ['file:///project'], - chats: [], - ...overrides, - }; - } - - function metadata(id: string, value = source()): IAgentSessionMetadata { - const session = AgentSession.uri('copilot', id); - let meta: SessionMeta | undefined; - meta = withSessionWorkspaceless(meta, value.workspaceless); - if (value.ehcliAdoptable) { - meta = withSessionEhcliAdoptable(meta); - } - meta = withSessionMultiRootMetadata(meta, value.multiRoot); - meta = withSessionFolderPickerDecision(meta, value.folderPicker); - meta = withSessionGitHubState(meta, value.github); - meta = withSessionGitState(meta, value.git); - meta = withSessionSourceControlState(meta, value.sourceControl ? { - merge: value.sourceControl.merge, - latestOutcome: value.sourceControl.latestOutcome === 'merge' ? SessionSourceControlOutcome.Merge : SessionSourceControlOutcome.PullRequest, - } : undefined); - meta = withSessionArtifacts(meta, value.artifacts?.map(artifact => ({ ...artifact, type: artifact.type as SessionArtifactType })) ?? []); - if (value.orchestration) { - meta = withSessionOrchestration(meta, value.orchestration); - } - return { - session, - startTime: 1, - modifiedTime: value.modifiedTime, - summary: value.title, - status: SessionStatus.Idle | (value.isRead ? SessionStatus.IsRead : 0) | (value.isArchived ? SessionStatus.IsArchived : 0), - project: value.project ? { uri: URI.parse(value.project.uri), displayName: value.project.displayName } : undefined, - workingDirectories: value.workingDirectories.map(directory => URI.parse(directory)), - changes: value.changes, - _meta: meta, - }; - } - - function registered(legacy: IAgentSessionMetadata, overrides: Partial = {}): IRegisteredSession { - return { - session: legacy.session, - provider: 'copilot', - startTime: legacy.startTime, - external: false, - source: 'explicit', - ...overrides, - }; - } - - function catalog(session: string, value = source(), options: { sessionGeneration?: string; provider?: IRegisteredSession['provider']; startTime?: number } = {}): IAgentHostDatabaseSessionV2 { - const projected = projectAgentHostCatalog(value, { - session, - sessionGeneration: options.sessionGeneration ?? 'incarnation', - sourceRevision: 0, - }); - assert.ok(projected.ok); - return { - ...projected.value.catalog, - provider: options.provider ?? 'copilot', - startTime: options.startTime ?? 1, - external: false, - source: 'explicit', - }; - } - - function seed(database: ShadowCatalogDatabase, legacy: IAgentSessionMetadata, value = source(), options: { sessionGeneration?: string; provider?: IRegisteredSession['provider']; startTime?: number } = {}): void { - const session = legacy.session.toString(); - database.catalogs.set(session, catalog(session, value, options)); - } - - function createValidator(database: ShadowCatalogDatabase, reporter: RecordingReporter, repair: () => void, concurrency?: number): AgentHostCatalogShadowValidator { - return new AgentHostCatalogShadowValidator(database, reporter, repair, new NullLogService(), concurrency === undefined ? {} : { concurrency }); - } - - test('reports a normalized match without exposing non-comparable title source or chats', async () => { - const database = disposables.add(new ShadowCatalogDatabase()); - const reporter = new RecordingReporter(); - const legacy = metadata('matched'); - seed(database, legacy); - let repairs = 0; - - await createValidator(database, reporter, () => repairs++).validate([legacy], [registered(legacy)]); - - assert.deepStrictEqual({ - total: reporter.reports[0].total, - matched: reporter.reports[0].counts.matched, - titleSourceNotComparable: reporter.reports[0].counts.titleSourceNotComparable, - chatsNotComparable: reporter.reports[0].counts.chatsNotComparable, - repairs, - }, { - total: 1, - matched: 1, - titleSourceNotComparable: 1, - chatsNotComparable: 1, - repairs: 0, - }); - }); - - test('categorizes missing, malformed, and validator exceptions and schedules one repair', async () => { - const database = disposables.add(new ShadowCatalogDatabase()); - const reporter = new RecordingReporter(); - const missing = metadata('missing'); - const malformed = metadata('malformed'); - const failed = metadata('failed'); - seed(database, malformed); - database.catalogs.set(malformed.session.toString(), { ...database.catalogs.get(malformed.session.toString()) as IAgentHostDatabaseSessionV2, title: 'not canonical' }); - database.catalogs.set(failed.session.toString(), new Error('sensitive: file:///private/path')); - let repairs = 0; - - await createValidator(database, reporter, () => repairs++).validate( - [missing, malformed, failed], - [missing, malformed, failed].map(entry => registered(entry)), - ); - - const report = reporter.reports[0]; - assert.deepStrictEqual({ - missing: report.counts.missing, - malformed: report.counts.malformed, - validationError: report.counts.validationError, - repairs, - containsSensitiveData: JSON.stringify(report).includes('private'), - }, { - missing: 1, - malformed: 1, - validationError: 1, - repairs: 1, - containsSensitiveData: false, - }); - }); - - test('reports every comparable field mismatch category', async () => { - const database = disposables.add(new ShadowCatalogDatabase()); - const reporter = new RecordingReporter(); - const cases: Array<{ category: keyof IAgentHostCatalogShadowValidationReport['counts']; mutate: (value: IAgentHostCatalogSource) => IAgentHostCatalogSource }> = [ - { category: 'modifiedTimeMismatch', mutate: value => ({ ...value, modifiedTime: 3 }) }, - { category: 'titleMismatch', mutate: value => ({ ...value, title: 'Different' }) }, - { category: 'readMismatch', mutate: value => ({ ...value, isRead: false }) }, - { category: 'archiveMismatch', mutate: value => ({ ...value, isArchived: false }) }, - { category: 'projectMismatch', mutate: value => ({ ...value, project: { uri: 'file:///other', displayName: 'Other' } }) }, - { category: 'workspacelessMismatch', mutate: value => ({ ...value, workspaceless: false }) }, - { category: 'adoptableMismatch', mutate: value => ({ ...value, ehcliAdoptable: false }) }, - { category: 'multiRootMismatch', mutate: value => ({ ...value, multiRoot: { workspaceFile: 'file:///other.code-workspace' } }) }, - { category: 'folderPickerMismatch', mutate: value => ({ ...value, folderPicker: { hidden: true } }) }, - { category: 'changesMismatch', mutate: value => ({ ...value, changes: { additions: 10 } }) }, - { category: 'githubMismatch', mutate: value => ({ ...value, github: { owner: 'other' } }) }, - { category: 'gitMismatch', mutate: value => ({ ...value, git: { ...value.git, branchName: 'other' } }) }, - { category: 'sourceControlMismatch', mutate: value => ({ ...value, sourceControl: { latestOutcome: 'pullRequest' } }) }, - { category: 'artifactsMismatch', mutate: value => ({ ...value, artifacts: [] }) }, - { category: 'orchestrationMismatch', mutate: value => ({ ...value, orchestration: { ...value.orchestration!, notifyOnIdle: 'always' } }) }, - { category: 'workingDirectoriesMismatch', mutate: value => ({ ...value, workingDirectories: ['file:///other'] }) }, - ]; - const legacySessions = cases.map((entry, index) => { - const legacy = metadata(`mismatch-${index}`); - seed(database, legacy, entry.mutate(source())); - return legacy; - }); - let repairs = 0; - - await createValidator(database, reporter, () => repairs++).validate(legacySessions, legacySessions.map(entry => registered(entry))); - - const counts = reporter.reports[0].counts; - assert.deepStrictEqual({ - mismatchCounts: cases.map(entry => [entry.category, counts[entry.category]]), - matched: counts.matched, - repairs, - }, { - mismatchCounts: cases.map(entry => [entry.category, 1]), - matched: 0, - repairs: 1, - }); - }); - - test('reports identity, provider, and start-time mismatches with only catalog identity repairable', async () => { - const database = disposables.add(new ShadowCatalogDatabase()); - const reporter = new RecordingReporter(); - const identity = metadata('identity'); - const provider = metadata('provider'); - const start = metadata('start'); - seed(database, identity); - seed(database, provider); - seed(database, start); - database.catalogs.set(identity.session.toString(), { ...database.catalogs.get(identity.session.toString()) as IAgentHostDatabaseSessionV2, session: 'copilot:/other' }); - database.catalogs.set(provider.session.toString(), { ...database.catalogs.get(provider.session.toString()) as IAgentHostDatabaseSessionV2, provider: 'claude' }); - database.catalogs.set(start.session.toString(), { ...database.catalogs.get(start.session.toString()) as IAgentHostDatabaseSessionV2, startTime: 99 }); - let repairs = 0; - - await createValidator(database, reporter, () => repairs++).validate( - [identity, provider, start], - [ - registered(identity), - registered(provider), - registered(start), - ], - ); - - assert.deepStrictEqual({ - identityMismatch: reporter.reports[0].counts.identityMismatch, - providerMismatch: reporter.reports[0].counts.providerMismatch, - startTimeMismatch: reporter.reports[0].counts.startTimeMismatch, - repairs, - }, { - identityMismatch: 1, - providerMismatch: 1, - startTimeMismatch: 1, - repairs: 1, - }); - - test('validates central-only rows against durable top-level eligibility', async () => { - const database = disposables.add(new ShadowCatalogDatabase()); - const reporter = new RecordingReporter(); - const backing = metadata('backing'); - const unexpectedTopLevel = metadata('unexpected-top-level'); - seed(database, backing, source({ isChatBacking: true })); - seed(database, unexpectedTopLevel); - let repairs = 0; - - await createValidator(database, reporter, () => repairs++).validate([], [ - registered(backing), - registered(unexpectedTopLevel), - ]); - - assert.deepStrictEqual({ - total: reporter.reports[0].total, - matched: reporter.reports[0].counts.matched, - topLevelEligibilityMismatch: reporter.reports[0].counts.topLevelEligibilityMismatch, - repairs, - }, { - total: 2, - matched: 1, - topLevelEligibilityMismatch: 1, - repairs: 1, - }); - }); - - test('detects a backing catalog row that legacy lists as top-level', async () => { - const database = disposables.add(new ShadowCatalogDatabase()); - const reporter = new RecordingReporter(); - const legacy = metadata('listed-backing'); - seed(database, legacy, source({ isChatBacking: true })); - let repairs = 0; - - await createValidator(database, reporter, () => repairs++).validate([legacy], [registered(legacy)]); - - assert.deepStrictEqual({ - topLevelEligibilityMismatch: reporter.reports[0].counts.topLevelEligibilityMismatch, - repairs, - }, { - topLevelEligibilityMismatch: 1, - repairs: 1, - }); - }); - }); - - test('bounds central validation concurrency', async () => { - const database = disposables.add(new ShadowCatalogDatabase()); - database.activeReadDelay = 5; - const reporter = new RecordingReporter(); - const sessions = Array.from({ length: 8 }, (_, index) => metadata(`concurrency-${index}`)); - for (const legacy of sessions) { - seed(database, legacy); - } - - await createValidator(database, reporter, () => { }, 2).validate(sessions, sessions.map(entry => registered(entry))); - - assert.deepStrictEqual({ - maxConcurrent: database.maxConcurrentActiveReads, - matched: reporter.reports[0].counts.matched, - }, { - maxConcurrent: 2, - matched: 8, - }); - }); - - test('logs and isolates a rejected background validation', async () => { - const database = disposables.add(new ShadowCatalogDatabase()); - const reporter = new RecordingReporter(); - const warning = new DeferredPromise(); - const logService = new class extends NullLogService { - override info(): void { - throw new Error('validation failed'); - } - - override warn(message: string): void { - warning.complete(message); - } - }; - const validator = new AgentHostCatalogShadowValidator(database, reporter, () => { }, logService); - - validator.schedule([], []); - - assert.strictEqual(await warning.p, '[AgentHostCatalogShadowValidator] Background validation failed'); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index f2d3266d245963..4352961f16541d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -8,9 +8,9 @@ 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 { SessionArtifactType, withSessionArtifacts } from '../../common/sessionArtifacts.js'; +import { SessionArtifactType, SESSION_META_ARTIFACTS_KEY, withSessionArtifacts } from '../../common/sessionArtifacts.js'; import { ChatOriginKind } from '../../common/state/protocol/state.js'; -import { AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionSourceControlOutcome, SessionStatus, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, withSessionSourceControlState, withSessionWorkspaceless } from '../../common/state/sessionState.js'; +import { AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_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_ORCHESTRATION_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionSourceControlOutcome, SessionStatus, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, withSessionSourceControlState, withSessionWorkspaceless } from '../../common/state/sessionState.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'; @@ -103,31 +103,33 @@ suite('AgentHostCatalogSourceResolver', () => { }, false); assert.deepStrictEqual(result, { - source: { + data: { modifiedTime: 123, - title: 'Override title', + summary: 'Override title', titleSource: 'user', isRead: false, isArchived: false, project: { uri: 'file:///persisted-worktree', displayName: 'Persisted worktree' }, - workspaceless: true, isChatBacking: true, - ehcliAdoptable: true, - ehcliAdopted: true, - multiRoot: { workspaceFile: 'file:///live.code-workspace' }, - folderPicker: { hidden: false }, changes: { additions: 1, deletions: 2, files: 3 }, - github: liveGitHub, - git: liveGit, - sourceControl: liveSourceControl, - artifacts: [liveArtifact], - orchestration: liveOrchestration, + _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_ORCHESTRATION_KEY]: liveOrchestration, + [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', - title: 'Override chat', + summary: 'Override chat', titleSource: 'agent', origin: { kind: ChatOriginKind.Fork, chat: 'agenthost-chat:source/default', turnId: 'turn-1' }, }], @@ -162,31 +164,32 @@ suite('AgentHostCatalogSourceResolver', () => { const result = await createResolver(persistedMetadata()).buildCatalogSyncRequest(session, sourceState(), {}, true); assert.deepStrictEqual(result, { - source: { + data: { modifiedTime: 123, - title: 'Persisted title', + summary: 'Persisted title', titleSource: 'user', isRead: true, isArchived: true, project: { uri: 'file:///persisted-worktree', displayName: 'Persisted worktree' }, - workspaceless: false, isChatBacking: true, - ehcliAdoptable: true, - ehcliAdopted: true, - multiRoot: { workspaceFile: 'file:///persisted.code-workspace' }, - folderPicker: { hidden: true, primary: 'file:///persisted' }, changes: { additions: 10, deletions: 20, files: 30 }, - github: persistedGitHub, - git: liveGit, - sourceControl: { merge: undefined, ...persistedSourceControl }, - artifacts: [persistedArtifact], - orchestration: persistedOrchestration, + _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_ORCHESTRATION_KEY]: persistedOrchestration, + [SESSION_META_EHCLI_ADOPTABLE_KEY]: true, + [SESSION_META_EHCLI_ADOPTED_KEY]: true, + }, workingDirectories: ['file:///live'], chats: [{ uri: chat, order: 0, kind: 'default', - title: 'Persisted chat', + summary: 'Persisted chat', titleSource: 'agent', origin: { kind: ChatOriginKind.Fork, chat: 'agenthost-chat:source/default', turnId: 'turn-1' }, }], diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts index 0405c3a5839f56..625aa35c2d0b47 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts @@ -9,32 +9,36 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c 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_PROJECTION_VERSION } from '../../node/agentHostCatalogProjection.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, AgentHostCatalogData } from '../../node/agentHostCatalogProjection.js'; import { AgentHostCatalogSyncService } from '../../node/agentHostCatalogSyncService.js'; -import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabaseSessionV2Envelope } from '../../node/agentHostDatabase.js'; import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; const session = URI.parse('agenthost:test-session'); -function source(title: string, chatTitle = title) { +function data(summary: string, chatSummary = summary): AgentHostCatalogData { return { modifiedTime: 1, - title, - titleSource: 'user' as const, + summary, + titleSource: 'user', isRead: false, isArchived: false, - workspaceless: true, workingDirectories: [], chats: [{ uri: 'agenthost-chat:test-session/default', order: 0, - kind: 'default' as const, - title: chatTitle, - titleSource: 'user' as const, + 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 }> = []; @@ -46,9 +50,9 @@ class RecordingSessionDatabase extends TestSessionDatabase { } override async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { - const persistedSource = JSON.parse(snapshot.payload).source; - this.calls.push(`local:${snapshot.sourceRevision}:${persistedSource.title}`); - this.writes.push({ metadata: { ...values }, title: persistedSource.title, chatTitle: persistedSource.chats[0].title }); + 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'); @@ -93,8 +97,8 @@ class RecordingCatalogDatabase extends AgentHostDatabase { return super.getSessionV2(session); } - override async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { - this.calls.push(`upsert:${projection.sourceRevision}:${projection.title}`); + 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; @@ -102,10 +106,10 @@ class RecordingCatalogDatabase extends AgentHostDatabase { if (this.seedConcurrentGeneration) { const generation = this.seedConcurrentGeneration; this.seedConcurrentGeneration = undefined; - await super.upsertSessionV2({ ...projection, sessionGeneration: generation }, expectedSessionGeneration); + await super.upsertSessionV2({ ...envelope, sessionGeneration: generation }, expectedSessionGeneration); return 'generationMismatch'; } - return this.upsertResult ?? super.upsertSessionV2(projection, expectedSessionGeneration); + return this.upsertResult ?? super.upsertSessionV2(envelope, expectedSessionGeneration); } } @@ -131,7 +135,7 @@ suite('AgentHostCatalogSyncService', () => { const order: string[] = []; const { local, central, service } = await createHarness(order); - const result = await service.synchronize(session, { source: source('one'), legacyMetadata: { customTitle: 'one' } }); + const result = await service.synchronize(session, { data: data('one'), legacyMetadata: { customTitle: 'one' } }); const snapshot = await local.getCatalogSyncSnapshot(); const catalog = await central.getSessionV2(session.toString()); @@ -141,11 +145,11 @@ suite('AgentHostCatalogSyncService', () => { localCalls: local.calls, title: await local.getMetadata('customTitle'), snapshot, - catalogTitle: catalog?.title, + catalogTitle: catalog && summaryOf(catalog.payload), receiptMatchesCatalog: snapshot?.sessionGeneration === catalog?.sessionGeneration && snapshot?.sourceRevision === catalog?.sourceRevision - && snapshot?.projectionVersion === catalog?.projectionVersion - && snapshot?.payloadHash === catalog?.sourceHash, + && snapshot?.projectionVersion === catalog?.payloadVersion + && snapshot?.payloadHash === catalog?.payloadHash, }, { result: { status: 'acknowledged', sourceRevision: 0 }, order: ['local', 'upsert', 'ack'], @@ -154,7 +158,7 @@ suite('AgentHostCatalogSyncService', () => { snapshot: { sessionGeneration: snapshot?.sessionGeneration, sourceRevision: 0, - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + projectionVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, payload: undefined, payloadHash: snapshot?.payloadHash, acknowledgedHash: snapshot?.payloadHash, @@ -169,7 +173,7 @@ suite('AgentHostCatalogSyncService', () => { const { local, central, service } = await createHarness(); local.failLocalWrite = true; - await assert.rejects(service.synchronize(session, { source: source('one'), legacyMetadata: { customTitle: 'one' } }), /local write failed/); + await assert.rejects(service.synchronize(session, { data: data('one'), legacyMetadata: { customTitle: 'one' } }), /local write failed/); assert.deepStrictEqual(central.calls, ['get']); }); @@ -177,7 +181,7 @@ suite('AgentHostCatalogSyncService', () => { const { local, central, service } = await createHarness(); central.upsertError = new Error('central unavailable'); - const result = await service.synchronize(session, { source: source('one'), legacyMetadata: { customTitle: 'one' } }); + const result = await service.synchronize(session, { data: data('one'), legacyMetadata: { customTitle: 'one' } }); const snapshot = await local.getCatalogSyncSnapshot(); assert.deepStrictEqual({ @@ -195,7 +199,7 @@ suite('AgentHostCatalogSyncService', () => { test('replays an acknowledged exact receipt without rewriting sessions_v2', async () => { const { local, central, service } = await createHarness(); - const request = { source: source('one'), legacyMetadata: { customTitle: 'one' } }; + const request = { data: data('one'), legacyMetadata: { customTitle: 'one' } }; const first = await service.synchronize(session, request); const callsAfterFirst = central.calls.length; @@ -218,77 +222,15 @@ suite('AgentHostCatalogSyncService', () => { test('advances the revision when legacy metadata changes without changing the projection hash', async () => { const { local, service } = await createHarness(); - const catalogSource = source('one'); + const catalogData = data('one'); await service.synchronize(session, { - source: catalogSource, + data: catalogData, legacyMetadata: { customTitle: 'one', [META_GIT_STATE]: '{"branch":"first"}' }, }); - - test('advances changed content beyond a newer local pending revision after central failure', async () => { - const { local, central, service } = await createHarness(); - await service.synchronize(session, { source: source('H0'), legacyMetadata: { customTitle: 'H0' } }); - central.upsertError = new Error('central unavailable'); - const failed = await service.synchronize(session, { source: source('H1'), legacyMetadata: { customTitle: 'H1' } }); - const pending = await local.getCatalogSyncSnapshot(); - central.upsertError = undefined; - - const recovered = await service.synchronize(session, { source: source('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: (await central.getSessionV2(session.toString()))?.title, - }, - 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, { source: source('H0'), legacyMetadata: { customTitle: 'H0' } }); - central.getError = new Error('central read unavailable'); - - const first = await service.synchronize(session, { source: source('H1'), legacyMetadata: { customTitle: 'H1' } }); - const second = await service.synchronize(session, { source: source('H2'), legacyMetadata: { customTitle: 'H2' } }); - const pending = await local.getCatalogSyncSnapshot(); - central.getError = undefined; - const recovered = await service.synchronize(session, { source: source('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: (await central.getSessionV2(session.toString()))?.title, - }, - 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, - }); - }); const first = await local.getCatalogSyncSnapshot(); const result = await service.synchronize(session, { - source: catalogSource, + data: catalogData, legacyMetadata: { customTitle: 'one', [META_GIT_STATE]: '{"branch":"second"}' }, }); const second = await local.getCatalogSyncSnapshot(); @@ -308,11 +250,73 @@ suite('AgentHostCatalogSyncService', () => { }); }); + 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, { source: source('one'), legacyMetadata: {} }); + const result = await service.synchronize(session, { data: data('one'), legacyMetadata: {} }); const snapshot = await local.getCatalogSyncSnapshot(); assert.deepStrictEqual({ @@ -330,7 +334,7 @@ suite('AgentHostCatalogSyncService', () => { test('delete and recreate uses a new session generation', async () => { const { local, central, service } = await createHarness(); - await service.synchronize(session, { source: source('one'), legacyMetadata: {} }); + await service.synchronize(session, { data: data('one'), legacyMetadata: {} }); const firstGeneration = (await local.getCatalogSyncSnapshot())?.sessionGeneration; await central.tombstoneAndUnregisterSession(session.toString()); await central.clearSessionTombstone(session.toString()); @@ -340,7 +344,7 @@ suite('AgentHostCatalogSyncService', () => { source: 'explicit', }, { checkTombstone: false }); - const result = await service.synchronize(session, { source: source('two'), legacyMetadata: {} }); + const result = await service.synchronize(session, { data: data('two'), legacyMetadata: {} }); const secondGeneration = (await local.getCatalogSyncSnapshot())?.sessionGeneration; assert.deepStrictEqual({ @@ -359,9 +363,9 @@ suite('AgentHostCatalogSyncService', () => { const { local, service } = await createHarness(); local.blockFirstWrite = new Promise(resolve => releaseFirstWrite = resolve); - const first = service.synchronize(session, { source: source('one', 'chat-one'), legacyMetadata: { customTitle: 'one' } }); - const second = service.synchronize(session, { source: source('two', 'chat-two'), legacyMetadata: { customTitle: 'two' } }); - const third = service.synchronize(session, { source: source('three', 'chat-three'), legacyMetadata: { customTitle: 'three' } }); + 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({ diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 1f015f2ba24b29..55b47e747aa492 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -5,12 +5,14 @@ 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 { 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, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseSessionV2Envelope } from '../../node/agentHostDatabase.js'; function openDatabase(path: string): Promise { return new Promise((resolve, reject) => { @@ -34,44 +36,51 @@ function close(database: Database): Promise { return new Promise((resolve, reject) => database.close(error => error ? reject(error) : resolve())); } -function createProjection( +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 = {}, -): IAgentHostDatabaseSessionV2Projection { + overrides: Partial = {}, +): IAgentHostDatabaseSessionV2Envelope { + const payload = overrides.payload ?? createPayload(session, sourceRevision); return { session, sessionGeneration, - modifiedTime: 100 + sourceRevision, - title: `Title ${sourceRevision}`, - titleSource: 'user', - isRead: true, - isArchived: false, - projectUri: 'file:///project', - projectDisplayName: 'Project', - workspaceless: false, - isChatBacking: false, - ehcliAdoptable: true, - ehcliAdopted: false, - workingDirectoriesJson: '["file:///project","file:///project/packages/app"]', - chatsJson: `[{"kind":"default","order":0,"title":"Default","titleSource":"auto","uri":"${session}#default"},{"kind":"peer","order":1,"originJson":"{\\"type\\":\\"subagent\\"}","title":"Peer","titleSource":"agent","uri":"${session}#peer"}]`, - multiRootJson: '{"workspaceFile":"file:///project.code-workspace"}', - folderPickerJson: '{"hidden":false,"primary":"file:///project"}', - changesSummaryJson: '{"files":2}', - githubSummaryJson: '{"owner":"microsoft","repo":"vscode"}', - gitSummaryJson: '{"branchName":"main"}', - sourceControlSummaryJson: '{"latestOutcome":"merge"}', - artifactsJson: '[{"id":"artifact","label":"Artifact","type":"file"}]', - orchestrationJson: '{"coordinateWithCreator":true,"creatorSession":"session://parent","parentSession":"session://parent"}', sourceRevision, - projectionVersion: 4, - sourceHash: `hash-${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 }; +} + async function createPublishedSessionsV2Database(path: string, version: 4 | 5 | 6): Promise { const database = await openDatabase(path); try { @@ -202,16 +211,12 @@ suite('AgentHostDatabase sessions_v2', () => { sessionV2Columns: sessionV2Columns.map(row => row.name), sessionV2ForeignKeys, }, { - version: [{ user_version: 7 }], + version: [{ user_version: 8 }], tables: ['metadata', 'sessions', 'sessions_v2'], sessionColumns: ['session_uri', 'provider', 'start_time', 'external', 'registration_source'], sessionV2Columns: [ - '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', 'is_chat_backing', 'ehcli_adopted', + 'session_uri', 'provider', 'start_time', 'external', 'registration_source', 'session_generation', + 'source_revision', 'payload_version', 'payload_hash', 'verified', 'payload', 'is_chat_backing', ], sessionV2ForeignKeys: [], }); @@ -221,7 +226,7 @@ suite('AgentHostDatabase sessions_v2', () => { } }); - test('upgrades published v4 through v6 rows through the independent v7 schema', async () => { + test('upgrades published v4 through v6 rows through v8 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`); @@ -253,41 +258,9 @@ suite('AgentHostDatabase sessions_v2', () => { assert.deepStrictEqual(results, [4, 5, 6].map(version => ({ version, - schemaVersion: [{ user_version: 7 }], + schemaVersion: [{ user_version: 8 }], foreignKeys: [], - published: { - session: `session://published-${version}`, - provider: 'copilot', - startTime: version, - external: true, - source: 'discovery', - sessionGeneration: `generation-${version}`, - modifiedTime: 100, - title: 'Published', - titleSource: 'user', - isRead: true, - isArchived: false, - projectUri: 'file:///project', - projectDisplayName: 'Project', - workspaceless: false, - isChatBacking: version >= 5, - ehcliAdoptable: true, - ehcliAdopted: version >= 6 ? true : undefined, - workingDirectoriesJson: '["file:///project"]', - chatsJson: '[]', - multiRootJson: '{}', - folderPickerJson: '{}', - changesSummaryJson: '{}', - githubSummaryJson: '{}', - gitSummaryJson: '{}', - sourceControlSummaryJson: '{}', - artifactsJson: '[]', - orchestrationJson: '{}', - sourceRevision: 7, - projectionVersion: 4, - sourceHash: 'published-hash', - verified: true, - }, + published: undefined, directLegacy: undefined, directCurrent: { session: `session://direct-${version}`, @@ -299,6 +272,52 @@ suite('AgentHostDatabase sessions_v2', () => { }))); }); + test('migrates v7 registry rows to the v8 envelope 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, + external: true, + source: 'discovery', + }, + projection: undefined, + rows: [{ + session_uri: 'session://published-6', + provider: 'copilot', + start_time: 6, + external: 1, + registration_source: 'discovery', + session_generation: 'generation-6', + source_revision: 7, + payload_version: 4, + payload_hash: 'published-hash', + verified: 0, + payload: null, + is_chat_backing: 1, + }], + }); + }); + test('upgrades published v1 through v3 schemas with incomplete v2 rows', async () => { const results: object[] = []; for (const version of [1, 2, 3]) { @@ -368,30 +387,57 @@ suite('AgentHostDatabase sessions_v2', () => { startTime: 42, source: 'restore', }, { checkTombstone: false }); - const projection = createProjection(session, 'generation-1', 7); + const registration = { provider: 'copilot', startTime: 42, external: false, source: 'restore' }; + const envelope = createEnvelope(session, 'generation-1', 7); - const result = await database.upsertSessionV2(projection, undefined); + 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: { - ...projection, - provider: 'copilot', - startTime: 42, - external: false, - source: 'restore', - }, - rows: [{ - ...projection, - provider: 'copilot', - startTime: 42, - external: false, - source: 'restore', - }], + 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], }); }); @@ -399,15 +445,15 @@ suite('AgentHostDatabase sessions_v2', () => { database = new AgentHostDatabase(':memory:'); const session = 'session://ordering'; await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); - await database.upsertSessionV2(createProjection(session, 'generation-1', 2), undefined); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 2), undefined); const results = { - stale: await database.upsertSessionV2(createProjection(session, 'generation-1', 1), 'generation-1'), - conflict: await database.upsertSessionV2(createProjection(session, 'generation-1', 2, { sourceHash: 'conflict' }), 'generation-1'), - replayed: await database.upsertSessionV2(createProjection(session, 'generation-1', 2), 'generation-1'), - wrongGeneration: await database.upsertSessionV2(createProjection(session, 'generation-2', 0), 'unknown-generation'), - transitioned: await database.upsertSessionV2(createProjection(session, 'generation-2', 0), 'generation-1'), - delayedOldGeneration: await database.upsertSessionV2(createProjection(session, 'generation-1', 3), 'generation-1'), + 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({ @@ -422,13 +468,7 @@ suite('AgentHostDatabase sessions_v2', () => { transitioned: 'applied', delayedOldGeneration: 'generationMismatch', }, - row: { - ...createProjection(session, 'generation-2', 0), - provider: 'copilot', - startTime: 1, - external: false, - source: 'explicit', - }, + row: storedRow(createEnvelope(session, 'generation-2', 0), { provider: 'copilot', startTime: 1, external: false, source: 'explicit' }), }); }); @@ -439,10 +479,10 @@ suite('AgentHostDatabase sessions_v2', () => { await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); } - const upsertResults = await Promise.all(sessions.map(session => database!.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined))); + 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(createProjection(racingSession, 'generation-1', 2), 'generation-1'), + database.upsertSessionV2(createEnvelope(racingSession, 'generation-1', 2), 'generation-1'), database.unregisterSessionV2(racingSession), ]); @@ -463,7 +503,7 @@ suite('AgentHostDatabase sessions_v2', () => { database = new AgentHostDatabase(':memory:'); const session = 'session://provenance'; await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); - await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); + 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 }); @@ -487,7 +527,7 @@ suite('AgentHostDatabase sessions_v2', () => { const session = 'session://external-backfill'; database = new AgentHostDatabase(path); await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); - await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined); await database.close(); database = undefined; @@ -566,7 +606,7 @@ suite('AgentHostDatabase sessions_v2', () => { const session = 'session://runtime-provenance'; database = new AgentHostDatabase(path); await database.registerRuntimeSession(session, { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); - await database.upsertSessionV2(createProjection(session, 'generation-1', 3), undefined); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 3), undefined); await database.close(); database = undefined; @@ -630,7 +670,7 @@ suite('AgentHostDatabase sessions_v2', () => { database = new AgentHostDatabase(path); const currentOnly = 'session://current-only'; await database.registerSessionV2(currentOnly, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); - await database.upsertSessionV2(createProjection(currentOnly, 'generation-1', 1), undefined); + 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(); @@ -649,13 +689,7 @@ suite('AgentHostDatabase sessions_v2', () => { oldBuildSessionV2: await database.getSessionV2Registration('session://old-build'), }, { currentOnlyLegacy: undefined, - currentOnlyV2: { - ...createProjection(currentOnly, 'generation-1', 1), - provider: 'copilot', - startTime: 1, - external: false, - source: 'explicit', - }, + currentOnlyV2: storedRow(createEnvelope(currentOnly, 'generation-1', 1), { provider: 'copilot', startTime: 1, external: false, source: 'explicit' }), oldBuildSession: { session: 'session://old-build', provider: 'copilot', startTime: 2, external: true, source: 'discovery' }, oldBuildSessionV2: undefined, }); @@ -666,7 +700,7 @@ suite('AgentHostDatabase sessions_v2', () => { 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(createProjection(session, 'generation-1', 1), undefined); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined); await database.close(); database = undefined; @@ -682,20 +716,8 @@ suite('AgentHostDatabase sessions_v2', () => { list: await database.listSessionsV2(), }, { orphanRows: [{ session_uri: session }], - get: { - ...createProjection(session, 'generation-1', 1), - provider: 'copilot', - startTime: 1, - external: false, - source: 'explicit', - }, - list: [{ - ...createProjection(session, 'generation-1', 1), - provider: 'copilot', - startTime: 1, - external: false, - source: 'explicit', - }], + get: storedRow(createEnvelope(session, 'generation-1', 1), { provider: 'copilot', startTime: 1, external: false, source: 'explicit' }), + list: [storedRow(createEnvelope(session, 'generation-1', 1), { provider: 'copilot', startTime: 1, external: false, source: 'explicit' })], }); }); @@ -703,7 +725,7 @@ suite('AgentHostDatabase sessions_v2', () => { database = new AgentHostDatabase(':memory:'); const session = 'session://tombstoned-read'; await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); - await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); + 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 }); @@ -723,7 +745,7 @@ suite('AgentHostDatabase sessions_v2', () => { }); }); - test('projection-versioned markers do not alter old marker semantics', async () => { + test('payload-versioned markers do not alter old marker semantics', async () => { database = new AgentHostDatabase(':memory:'); await database.markSessionRegistryBackfilled(); await database.markProviderBackfilled('copilot'); @@ -766,7 +788,7 @@ suite('AgentHostDatabase sessions_v2', () => { database = new AgentHostDatabase(':memory:'); const session = 'copilot:/excluded'; await database.registerSessionV2(session, { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); - await database.upsertSessionV2(createProjection(session, 'generation-1', 1), undefined); + await database.upsertSessionV2(createEnvelope(session, 'generation-1', 1), undefined); await database.excludeSessionV2({ provider: 'copilot', session, @@ -817,7 +839,7 @@ suite('AgentHostDatabase sessions_v2', () => { 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(createProjection(excluded, 'generation-1', 1), undefined); + await database.upsertSessionV2(createEnvelope(excluded, 'generation-1', 1), undefined); await database.excludeSessionV2({ provider: 'copilot', @@ -825,7 +847,7 @@ suite('AgentHostDatabase sessions_v2', () => { reason: 'staleExternal', fingerprint: '1', }); - const excludedUpsert = await database.upsertSessionV2(createProjection(excluded, 'generation-1', 2), 'generation-1'); + 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?.([{ diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index b12845f90c19b6..31e6ed14494fe3 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -41,15 +41,14 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js'; -import { AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_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, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionGitState, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_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, SESSION_META_ORCHESTRATION_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionGitState, withSessionMultiRootMetadata, withSessionOrchestration, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionGitState, type ISessionOrchestration, 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 { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; -import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; import type { AgentHostCatalogReconciliationSourceResult } from '../../node/agentHostCatalogReconciliationService.js'; -import type { AgentHostCatalogReadMode, IAgentHostCatalogShadowValidationReport, IAgentHostCatalogShadowValidationReporter } from '../../node/agentHostCatalogShadowValidator.js'; -import { AGENT_HOST_CATALOG_PROJECTION_VERSION, projectAgentHostCatalog, type IAgentHostCatalogSource } from '../../node/agentHostCatalogProjection.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, 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_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; @@ -67,7 +66,7 @@ import { SessionServerToolName } from '../../common/serverToolNames.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; -import { readSessionArtifacts, SessionArtifactType, withSessionArtifacts } from '../../common/sessionArtifacts.js'; +import { readSessionArtifacts, SESSION_META_ARTIFACTS_KEY, SessionArtifactType, withSessionArtifacts } from '../../common/sessionArtifacts.js'; import { createTestAgentService, getTestAgentServiceComposition, getTestAgentStateManager } from './agentServiceTestUtils.js'; /** @@ -155,46 +154,68 @@ function createPerSessionDataService(): { readonly service: ISessionDataService; }; } +/** 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 source: IAgentHostCatalogSource = { + const envelope = catalogEnvelope(session, { modifiedTime: 1, - title: 'verified', + summary: 'verified', isRead, isArchived: false, - workspaceless: false, workingDirectories: [], chats: [{ uri: buildDefaultChatUri(session), order: 0, kind: 'default' }], - }; - const projection = projectAgentHostCatalog(source, { - session: session.toString(), - sessionGeneration: 'verified-generation', - sourceRevision: 0, - }); - assert.strictEqual(projection.ok, true); - if (!projection.ok) { - return; - } + }, 'verified-generation', 0); await database.registerSessionV2(session.toString(), { provider, startTime: 1, source: external ? 'discovery' : 'restore', }, { checkTombstone: true }); await sessionData.setMetadataValuesAndCatalogSyncSnapshot({}, { - sessionGeneration: projection.value.catalog.sessionGeneration, - sourceRevision: projection.value.catalog.sourceRevision, - projectionVersion: projection.value.catalog.projectionVersion, - payload: projection.value.sourcePayload, - payloadHash: projection.value.catalog.sourceHash, + sessionGeneration: envelope.sessionGeneration, + sourceRevision: envelope.sourceRevision, + projectionVersion: envelope.payloadVersion, + payload: envelope.payload, + payloadHash: envelope.payloadHash, state: 'pending', }); - assert.strictEqual(await database.upsertSessionV2(projection.value.catalog, undefined), 'applied'); + assert.strictEqual(await database.upsertSessionV2(envelope, undefined), 'applied'); assert.strictEqual(await sessionData.acknowledgeCatalogSyncSnapshot({ - sessionGeneration: projection.value.catalog.sessionGeneration, - sourceRevision: projection.value.catalog.sourceRevision, - projectionVersion: projection.value.catalog.projectionVersion, - payloadHash: projection.value.catalog.sourceHash, + sessionGeneration: envelope.sessionGeneration, + sourceRevision: envelope.sourceRevision, + projectionVersion: envelope.payloadVersion, + payloadHash: envelope.payloadHash, }), true); } @@ -302,12 +323,12 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._sessions.set(session.session, { ...session, external: undefined }); } - setSessionV2ProjectionVersion(session: URI, projectionVersion: number): void { + 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, projectionVersion }); + this._sessionsV2.set(session.toString(), { ...catalog, payloadVersion, sessionGeneration }); } failRegistryWrites(count: number): void { @@ -573,17 +594,20 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { async getSessionV2(session: string): Promise { return this._sessionsV2.get(session); } async listSessionsV2(): Promise { return [...this._sessionsV2.values()]; } - async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { + async listSessionsV2Receipts(): Promise { + return [...this._sessionsV2.values()].map(({ payload, ...receipt }) => receipt); + } + async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { this.sessionV2UpsertAttempts++; - const session = this._sessionV2Registrations.get(projection.session); + const session = this._sessionV2Registrations.get(envelope.session); if (!session) { return 'missingSession'; } - const current = this._sessionsV2.get(projection.session); + const current = this._sessionsV2.get(envelope.session); if (current?.sessionGeneration !== expectedSessionGeneration) { return 'generationMismatch'; } - this._sessionsV2.set(projection.session, { ...session, ...projection }); + this._sessionsV2.set(envelope.session, { ...session, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload) }); return 'applied'; } @@ -815,19 +839,23 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { this.catalogListCalls++; return [...this._sessionsV2.values()]; } - async upsertSessionV2(projection: IAgentHostDatabaseSessionV2Projection, expectedSessionGeneration: string | undefined): Promise { - const session = this._sessionV2Registrations.get(projection.session); + async listSessionsV2Receipts(): Promise { + this.catalogListCalls++; + return [...this._sessionsV2.values()].map(({ payload, ...receipt }) => receipt); + } + async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { + const session = this._sessionV2Registrations.get(envelope.session); if (!session) { return 'missingSession'; } - const current = this._sessionsV2.get(projection.session); + const current = this._sessionsV2.get(envelope.session); if (current?.sessionGeneration !== expectedSessionGeneration) { return 'generationMismatch'; } - if (current?.sessionGeneration === projection.sessionGeneration && current.sourceRevision === projection.sourceRevision) { - return current.projectionVersion === projection.projectionVersion && current.sourceHash === projection.sourceHash ? 'replayed' : 'conflict'; + if (current?.sessionGeneration === envelope.sessionGeneration && current.sourceRevision === envelope.sourceRevision) { + return current.payloadVersion === envelope.payloadVersion && current.payloadHash === envelope.payloadHash ? 'replayed' : 'conflict'; } - this._sessionsV2.set(projection.session, { ...session, ...projection }); + this._sessionsV2.set(envelope.session, { ...session, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload) }); return 'applied'; } @@ -3282,27 +3310,28 @@ suite('AgentService (node dispatcher)', () => { const entry = this.catalog.get(AgentSession.id(session)); return entry ? { chat, startTime: entry.modifiedTime, modifiedTime: entry.modifiedTime, ...(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._meta ? { _meta: entry._meta } : {}) } : undefined; + } } class CentralCatalogDatabase extends TestAgentHostOrchestratorDatabase { - private readonly _catalogs = new Map(); + private readonly _catalogs = new Map(); - setCatalog(session: URI, source: IAgentHostCatalogSource): void { - const projection = projectAgentHostCatalog(source, { - session: session.toString(), - sessionGeneration: 'test-generation', - sourceRevision: 1, - }); - if (!projection.ok) { - throw new Error(projection.error.message); - } - this._catalogs.set(session.toString(), projection.value.catalog); + setCatalog(session: URI, data: AgentHostCatalogData): void { + this._catalogs.set(session.toString(), catalogEnvelope(session, data)); } override async getSessionV2(session: string): Promise { - const catalog = this._catalogs.get(session); + const envelope = this._catalogs.get(session); const registered = await this.getSessionV2Registration(session); - return catalog && registered ? { ...registered, ...catalog } : undefined; + return envelope && registered + ? { ...registered, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload) } + : undefined; } } @@ -3315,14 +3344,13 @@ suite('AgentService (node dispatcher)', () => { } } - function centralSource(modifiedTime: number, title: string, ehcliAdoptable = false): IAgentHostCatalogSource { + function centralData(modifiedTime: number, summary: string, ehcliAdoptable = false): AgentHostCatalogData { return { modifiedTime, - title, + summary, isRead: false, isArchived: false, - workspaceless: false, - ehcliAdoptable, + ...(ehcliAdoptable ? { _meta: { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true } } : {}), workingDirectories: [], chats: [], }; @@ -3347,12 +3375,7 @@ suite('AgentService (node dispatcher)', () => { )); } - function createCatalogReadModeService( - sessionDataService: ISessionDataService, - orchestratorDatabase: IAgentHostDatabase, - readMode: AgentHostCatalogReadMode, - reporter: IAgentHostCatalogShadowValidationReporter, - ): AgentService { + function createCentralCatalogService(sessionDataService: ISessionDataService, orchestratorDatabase: IAgentHostDatabase): AgentService { return disposables.add(createTestAgentService( new NullLogService(), fileService, @@ -3368,8 +3391,6 @@ suite('AgentService (node dispatcher)', () => { undefined, undefined, orchestratorDatabase, - readMode, - reporter, )); } @@ -3424,35 +3445,6 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions.length, 1); }); - test('listSessions does not read the central catalog during dual write', async () => { - const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); - const svc = disposables.add(createTestAgentService( - new NullLogService(), - fileService, - createSessionDataService(), - { _serviceBrand: undefined } as IProductService, - createNoopGitService(), - undefined, - undefined, - undefined, - undefined, - undefined, - [], - undefined, - undefined, - orchestratorDatabase, - )); - const agent = new MockAgent('copilot'); - disposables.add(toDisposable(() => agent.dispose())); - svc.registerProvider(agent); - await svc.createSession({ provider: 'copilot' }); - orchestratorDatabase.catalogListCalls = 0; - - await svc.listSessions(); - - assert.strictEqual(orchestratorDatabase.catalogListCalls, 0); - }); - 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'); @@ -3461,14 +3453,14 @@ suite('AgentService (node dispatcher)', () => { startTime: 10, source: 'explicit', }, { checkTombstone: false }); - orchestratorDatabase.setCatalog(session, centralSource(20, 'Central')); + 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, { ...centralSource(21, 'Backing'), isChatBacking: true }); + orchestratorDatabase.setCatalog(backingSession, { ...centralData(21, 'Backing'), isChatBacking: true }); let databaseOpens = 0; const sessionDataService: ISessionDataService = { ...createSessionDataService(), @@ -3477,7 +3469,7 @@ suite('AgentService (node dispatcher)', () => { throw new Error('central list must not open session.db'); }, }; - const svc = createCatalogReadModeService(sessionDataService, orchestratorDatabase, 'centralWithFallback', { report: () => { } }); + const svc = createCentralCatalogService(sessionDataService, orchestratorDatabase); await svc.whenCatalogReconciliationIdle(); databaseOpens = 0; const agent = disposables.add(new CountingMetadataAgent('copilot')); @@ -3499,71 +3491,54 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('central modes gate adoptable catalogs without provider or session database reads', async () => { - const outcomes: object[] = []; - for (const readMode of ['centralWithFallback', 'central'] as const) { - const orchestratorDatabase = new CentralCatalogDatabase(); - const session = AgentSession.uri('copilot', `central-adoptable-${readMode}`); - await orchestratorDatabase.registerSessionV2(session.toString(), { - provider: 'copilot', - startTime: 10, - source: 'explicit', - }, { checkTombstone: false }); - orchestratorDatabase.setCatalog(session, centralSource(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 = createCatalogReadModeService(sessionDataService, orchestratorDatabase, readMode, { report: () => { } }); - await svc.whenCatalogReconciliationIdle(); - databaseOpens = 0; - const agent = disposables.add(new CountingMetadataAgent('copilot')); - svc.registerProvider(agent); - await timeout(0); - agent.metadataCalls = []; - databaseOpens = 0; + test('central list gates adoptable catalogs 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')); + svc.registerProvider(agent); + await timeout(0); + agent.metadataCalls = []; + databaseOpens = 0; - 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, centralSource(20, 'Adopted')); - getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); - (svc as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); - const afterAdoption = await svc.listSessions(); - - outcomes.push({ - readMode, - whileDisabled: whileDisabled.length, - whileEnabled: whileEnabled.map(metadata => metadata.summary), - afterAdoption: afterAdoption.map(metadata => metadata.summary), - providerMetadataCalls: agent.metadataCalls, - sessionDatabaseOpens: databaseOpens, - }); - } + 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(outcomes, [ - { - readMode: 'centralWithFallback', - whileDisabled: 0, - whileEnabled: ['Adoptable'], - afterAdoption: ['Adopted'], - providerMetadataCalls: [], - sessionDatabaseOpens: 0, - }, - { - readMode: 'central', - whileDisabled: 0, - whileEnabled: ['Adoptable'], - afterAdoption: ['Adopted'], - providerMetadataCalls: [], - sessionDatabaseOpens: 0, - }, - ]); + assert.deepStrictEqual({ + whileDisabled: whileDisabled.length, + whileEnabled: whileEnabled.map(metadata => metadata.summary), + afterAdoption: afterAdoption.map(metadata => metadata.summary), + providerMetadataCalls: agent.metadataCalls, + sessionDatabaseOpens: databaseOpens, + }, { + whileDisabled: 0, + whileEnabled: ['Adoptable'], + afterAdoption: ['Adopted'], + providerMetadataCalls: [], + sessionDatabaseOpens: 0, + }); }); test('central fallback accesses provider and session database only for the ineligible session', async () => { @@ -3577,7 +3552,7 @@ suite('AgentService (node dispatcher)', () => { source: 'explicit', }, { checkTombstone: false }); } - orchestratorDatabase.setCatalog(centralSession, centralSource(30, 'Central')); + orchestratorDatabase.setCatalog(centralSession, centralData(30, 'Central')); const databaseOpens: string[] = []; const baseSessionDataService = createSessionDataService(); const sessionDataService: ISessionDataService = { @@ -3587,7 +3562,7 @@ suite('AgentService (node dispatcher)', () => { return baseSessionDataService.tryOpenDatabase(session); }, }; - const svc = createCatalogReadModeService(sessionDataService, orchestratorDatabase, 'centralWithFallback', { report: () => { } }); + const svc = createCentralCatalogService(sessionDataService, orchestratorDatabase); await svc.whenCatalogReconciliationIdle(); const agent = disposables.add(new CountingMetadataAgent('copilot')); agent.addSession('eligible', 30); @@ -3613,7 +3588,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('central mode omits ineligible rows and lists eligible rows without a provider or session database', async () => { + 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'); @@ -3624,7 +3599,7 @@ suite('AgentService (node dispatcher)', () => { source: 'explicit', }, { checkTombstone: false }); } - orchestratorDatabase.setCatalog(eligible, centralSource(40, 'Available centrally')); + orchestratorDatabase.setCatalog(eligible, centralData(40, 'Available centrally')); let databaseOpens = 0; const sessionDataService: ISessionDataService = { ...createSessionDataService(), @@ -3633,7 +3608,7 @@ suite('AgentService (node dispatcher)', () => { return undefined; }, }; - const svc = createCatalogReadModeService(sessionDataService, orchestratorDatabase, 'central', { report: () => { } }); + const svc = createCentralCatalogService(sessionDataService, orchestratorDatabase); await svc.whenCatalogReconciliationIdle(); databaseOpens = 0; @@ -3650,13 +3625,13 @@ suite('AgentService (node dispatcher)', () => { test('central list applies the same live state overlay as legacy listing', async () => { const orchestratorDatabase = new CentralCatalogDatabase(); - const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'central', { report: () => { } }); + const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); const session = await svc.createSession({ provider: 'copilot' }); await svc.whenCatalogReconciliationIdle(); orchestratorDatabase.setCatalog(session, { - ...centralSource(20, 'Persisted title'), + ...centralData(20, 'Persisted title'), workingDirectories: ['file:///persisted'], changes: { files: 1 }, }); @@ -3679,7 +3654,7 @@ suite('AgentService (node dispatcher)', () => { }, { title: 'Live title', workingDirectories: ['file:///persisted'], - changes: { additions: undefined, deletions: undefined, files: 1 }, + changes: { files: 1 }, git: { branchName: 'live-branch' }, }); }); @@ -3696,9 +3671,9 @@ suite('AgentService (node dispatcher)', () => { startTime: index, source: 'discovery', }, { checkTombstone: false }); - orchestratorDatabase.setCatalog(session, centralSource(now - index, `Session ${index}`)); + orchestratorDatabase.setCatalog(session, centralData(now - index, `Session ${index}`)); } - const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'central', { report: () => { } }); + const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); await svc.whenCatalogReconciliationIdle(); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); await waitForSessionListReconciliation(svc); @@ -3719,7 +3694,7 @@ suite('AgentService (node dispatcher)', () => { startTime: 10, source: 'explicit', }, { checkTombstone: false }); - const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'centralWithFallback', { report: () => { } }); + const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); await svc.whenCatalogReconciliationIdle(); const agent = disposables.add(new CountingMetadataAgent('copilot')); agent.addSession('repair-later', 20); @@ -3741,68 +3716,7 @@ suite('AgentService (node dispatcher)', () => { await reconciliationStarted.p; }); - test('shadow list returns the exact legacy result without additional session database opens', async () => { - const sessionDatabase = new TestSessionDatabase(); - const baseSessionDataService = createSessionDataService(sessionDatabase); - let listDatabaseOpens = 0; - const sessionDataService: ISessionDataService = { - ...baseSessionDataService, - tryOpenDatabase: async session => { - listDatabaseOpens++; - return baseSessionDataService.tryOpenDatabase(session); - }, - }; - const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); - const reports: IAgentHostCatalogShadowValidationReport[] = []; - const reportReceived = new DeferredPromise(); - const svc = createCatalogReadModeService(sessionDataService, orchestratorDatabase, 'legacy', { - report: report => { - reports.push(report); - reportReceived.complete(); - }, - }); - const agent = disposables.add(new MockAgent('copilot')); - svc.registerProvider(agent); - const session = await svc.createSession({ provider: 'copilot' }); - const projected = await orchestratorDatabase.getSessionV2(session.toString()); - agent.sessionMetadataOverrides = { startTime: projected!.startTime }; - exposeListedSessions(svc, [{ - session, - startTime: projected!.startTime, - modifiedTime: 1, - summary: 'Session', - status: SessionStatus.Idle, - }]); - - const legacyStartOpens = listDatabaseOpens; - const legacy = await svc.listSessions(); - const legacyOpens = listDatabaseOpens - legacyStartOpens; - (svc as unknown as { _catalogReadMode: AgentHostCatalogReadMode })._catalogReadMode = 'shadow'; - const shadowStartOpens = listDatabaseOpens; - const shadow = await svc.listSessions(); - const shadowOpens = listDatabaseOpens - shadowStartOpens; - await reportReceived.p; - - const comparable = (metadata: IAgentSessionMetadata) => ({ - ...metadata, - session: metadata.session.toString(), - startTime: 0, - project: metadata.project ? { ...metadata.project, uri: metadata.project.uri.toString() } : undefined, - workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()), - }); - assert.deepStrictEqual(shadow.map(comparable), legacy.map(comparable)); - assert.deepStrictEqual({ - openCountsEqual: shadowOpens === legacyOpens, - legacyOpenedDatabase: legacyOpens > 0, - reports: reports.map(report => ({ total: report.total, matched: report.counts.matched })), - }, { - openCountsEqual: true, - legacyOpenedDatabase: true, - reports: [{ total: 1, matched: 1 }], - }); - }); - - test('shadow list returns before validation and coalesces repeated lists to one latest pass', async () => { + 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 { @@ -3821,66 +3735,30 @@ suite('AgentService (node dispatcher)', () => { } } const orchestratorDatabase = new DeferredCatalogDatabase(); - const reports: IAgentHostCatalogShadowValidationReport[] = []; - const twoReportsReceived = new DeferredPromise(); - const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'legacy', { - report: report => { - reports.push(report); - if (reports.length === 2) { - twoReportsReceived.complete(); - } - }, - }); + const svc = createCentralCatalogService(createSessionDataService(), orchestratorDatabase); const agent = disposables.add(new MockAgent('copilot')); svc.registerProvider(agent); await svc.createSession({ provider: 'copilot' }); - (svc as unknown as { _catalogReadMode: AgentHostCatalogReadMode })._catalogReadMode = 'shadow'; + await svc.whenCatalogReconciliationIdle(); orchestratorDatabase.deferActiveCatalogReads = true; - const firstList = await svc.listSessions(); + const blocked = svc.listSessions(); await firstReadStarted.p; - const repeatedLists = await Promise.all([svc.listSessions(), svc.listSessions(), svc.listSessions()]); - - assert.deepStrictEqual({ - firstListCount: firstList.length, - repeatedListCounts: repeatedLists.map(list => list.length), - activeCatalogReadsWhileBlocked: orchestratorDatabase.activeCatalogReads, - reportsWhileBlocked: reports.length, - }, { - firstListCount: 1, - repeatedListCounts: [1, 1, 1], - activeCatalogReadsWhileBlocked: 1, - reportsWhileBlocked: 0, - }); - + const repeated = Promise.all([svc.listSessions(), svc.listSessions(), svc.listSessions()]); + const readsWhileBlocked = orchestratorDatabase.activeCatalogReads; releaseFirstRead.complete(); - await twoReportsReceived.p; + assert.deepStrictEqual({ + readsWhileBlocked, + blockedListCount: (await blocked).length, + repeatedListCounts: (await repeated).map(list => list.length), activeCatalogReads: orchestratorDatabase.activeCatalogReads, - reports: reports.length, }, { - activeCatalogReads: 2, - reports: 2, - }); - }); - - test('shadow reporter failure cannot fail listSessions', async () => { - const orchestratorDatabase = new TestAgentHostOrchestratorDatabase(); - const reportAttempted = new DeferredPromise(); - const svc = createCatalogReadModeService(createSessionDataService(), orchestratorDatabase, 'shadow', { - report: () => { - reportAttempted.complete(); - throw new Error('reporter failed'); - }, + readsWhileBlocked: 1, + blockedListCount: 1, + repeatedListCounts: [1, 1, 1], + activeCatalogReads: 1, }); - const agent = disposables.add(new MockAgent('copilot')); - svc.registerProvider(agent); - await svc.createSession({ provider: 'copilot' }); - - const listed = await svc.listSessions(); - await reportAttempted.p; - - assert.strictEqual(listed.length, 1); }); test('listSessions discovers provider-native sessions as external and restore preserves provenance', async () => { @@ -4199,7 +4077,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); @@ -4901,7 +4781,7 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION), + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), oldGlobalMarker: await database.isSessionRegistryBackfilled(), oldProviderMarker: await database.isProviderBackfilled('copilot'), }, { @@ -4917,7 +4797,7 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION); + 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'); @@ -5073,7 +4953,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ persistedRead: await perSession.database(session).getMetadata(AH_META_IS_READ_DB_KEY), - catalogRead: (await database.getSessionV2(session.toString()))?.isRead, + catalogRead: catalogDataOf(await database.getSessionV2(session.toString()))?.isRead, }, { persistedRead: '', catalogRead: false, @@ -5131,7 +5011,7 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION), + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), catalogCalls: agent.catalogCalls, }, { registrations: [verified.toString(), incomplete.toString(), missing.toString()].sort(), @@ -5199,7 +5079,7 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION), + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), }, { legacy: [ { session: intermediate.toString(), source: 'restore' }, @@ -5246,7 +5126,7 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION), + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), }, { imported: [imported.toString(), intermediateLegacy.toString()].sort(), firstCatalogCalls: 1, @@ -5264,7 +5144,7 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION); + 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')); @@ -5303,7 +5183,7 @@ suite('AgentService (node dispatcher)', () => { external: false, source: 'explicit', }); - await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_VERSION); + 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; @@ -5345,7 +5225,7 @@ suite('AgentService (node dispatcher)', () => { source: 'explicit', }); await database.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: true }); - await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_VERSION); + 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; @@ -5371,7 +5251,7 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION); + 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; @@ -5396,7 +5276,7 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION); + 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); @@ -5419,14 +5299,31 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('marker rerun upgrades an outdated projection without forcing an existing external row read', async () => { + 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, ''); - database.setSessionV2ProjectionVersion(session, AGENT_HOST_CATALOG_PROJECTION_VERSION - 1); - await database.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_VERSION); + 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); @@ -5435,13 +5332,13 @@ suite('AgentService (node dispatcher)', () => { await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, false); - const projection = await database.getSessionV2(session.toString()); + const stored = await database.getSessionV2(session.toString()); assert.deepStrictEqual({ - projectionVersion: projection?.projectionVersion, - isRead: projection?.isRead, + payloadVersion: stored?.payloadVersion, + isRead: catalogDataOf(stored)?.isRead, catalogCalls: agent.catalogCalls, }, { - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + payloadVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, isRead: false, catalogCalls: 0, }); @@ -5506,13 +5403,13 @@ suite('AgentService (node dispatcher)', () => { agent.catalog = undefined; svc.registerProvider(agent); await assert.rejects(svc.listSessions(), /cannot enumerate its native session catalog yet/); - const markerWhileUnavailable = await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_VERSION); + 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_PROJECTION_VERSION), + marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), }; failOneSession = false; @@ -5522,7 +5419,7 @@ suite('AgentService (node dispatcher)', () => { markerWhileUnavailable, afterFailedPass, finalCatalog: (await database.listSessionsV2()).map(row => row.session).sort(), - finalMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_VERSION), + finalMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), }, { markerWhileUnavailable: false, afterFailedPass: { catalog: [sibling.toString()], marker: false }, @@ -5561,7 +5458,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ exclusionAfterEnumeration, incompleteIdentityAfterEnumeration, - marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_VERSION), + 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, @@ -5597,7 +5494,7 @@ suite('AgentService (node dispatcher)', () => { 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_PROJECTION_VERSION), + marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), catalogCalls: agent.catalogCalls, metadataCalls: agent.metadataCalls, markerFastDatabaseOpens: perSession.databaseOpens, @@ -5683,14 +5580,14 @@ suite('AgentService (node dispatcher)', () => { registrations: (await database.listSessionV2Registrations()).map(row => row.session), catalog: catalog.map(row => ({ session: row.session, - adoptable: row.ehcliAdoptable, - adopted: row.ehcliAdopted, + 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_PROJECTION_VERSION), + currentMarker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), }, { registrations: [adoptable.toString()], catalog: [{ session: adoptable.toString(), adoptable: true, adopted: false }], @@ -6257,8 +6154,6 @@ 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; - let shadowReports = 0; - const shadowReportReceived = new DeferredPromise(); const svc = disposables.add(createTestAgentService( new NullLogService(), fileService, @@ -6274,13 +6169,6 @@ suite('AgentService (node dispatcher)', () => { undefined, undefined, db, - 'shadow', - { - report: () => { - shadowReports++; - shadowReportReceived.complete(); - }, - }, )); getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); const agent = disposables.add(new NotYetMigratableAgent('copilot')); @@ -6310,26 +6198,21 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ registryWrites: db.registryWriteAttempts, registered: (await svc.getRegisteredSessions()).map(session => session.toString()), - shadowReports, }, { registryWrites: writesBeforeUnavailable, registered: [], - shadowReports: 0, }); agent.enumerable = true; const listed = await svc.listSessions(); - await shadowReportReceived.p; assert.deepStrictEqual({ retriedBeforeFailure: callsAfterFailure > 1, retriedAfterFailure: agent.migrationCalls > callsAfterFailure, listed: listed.map(session => session.session.toString()).sort(), - shadowReports, }, { retriedBeforeFailure: true, retriedAfterFailure: true, listed: [existing.toString(), legacy.toString()].sort(), - shadowReports: 1, }); }); @@ -6430,8 +6313,8 @@ suite('AgentService (node dispatcher)', () => { callsAfterFailure, finalCalls: { copilot: copilot.catalogCalls, claude: claude.catalogCalls }, backfilled: { - copilot: await db.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_VERSION), - claude: await db.isSessionsV2Backfilled('claude', AGENT_HOST_CATALOG_PROJECTION_VERSION), + 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(), @@ -7030,8 +6913,6 @@ suite('AgentService (node dispatcher)', () => { return []; }; const catalogDatabase = new TestAgentHostOrchestratorDatabase(); - const reports: IAgentHostCatalogShadowValidationReport[] = []; - const reportReceived = new DeferredPromise(); const svc = disposables.add(createTestAgentService( new NullLogService(), fileService, @@ -7047,36 +6928,29 @@ suite('AgentService (node dispatcher)', () => { undefined, undefined, catalogDatabase, - 'legacy', - { report: report => { reports.push(report); reportReceived.complete(); } }, )); getConfigurationService(svc).updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days }); svc.registerProvider(agent); + // The first listing predates the catalog row and falls back; the second is served centrally. const sessions = await svc.listSessions(); await svc.whenCatalogReconciliationIdle(); - const projected = await catalogDatabase.getSessionV2(sessionUri.toString()); - (svc as unknown as { _catalogReadMode: AgentHostCatalogReadMode })._catalogReadMode = 'central'; + 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(); - (svc as unknown as { _catalogReadMode: AgentHostCatalogReadMode })._catalogReadMode = 'shadow'; - await svc.listSessions(); - await reportReceived.p; - // Twice, because the deleted repair cached per session: one listing cannot tell "never resolves" from "resolves once". - 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 }, - projectedProject: projected && { uri: projected.projectUri, displayName: projected.projectDisplayName }, - shadowProjectMismatches: reports.at(-1)?.counts.projectMismatch, + 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' }, - projectedProject: { uri: linkedCheckout.toString(), displayName: 'parent' }, - shadowProjectMismatches: 0, + storedProject: { uri: linkedCheckout.toString(), displayName: 'parent' }, persistedRepositoryRoot: linkedCheckout.toString(), }); }); @@ -7182,6 +7056,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); @@ -7290,34 +7168,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); - service.registerProvider(agent); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); + await waitForSessionListReconciliation(svc); + svc.registerProvider(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', @@ -7327,7 +7231,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({ @@ -8882,7 +8786,7 @@ suite('AgentService (node dispatcher)', () => { const db = new TransientRegistryWriteDatabase(); const session = AgentSession.uri('copilot', 'registered-but-unavailable'); await db.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); - await db.markSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PROJECTION_VERSION); + 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(); @@ -9015,7 +8919,7 @@ suite('AgentService (node dispatcher)', () => { const central = await (localService as unknown as { _orchestratorDatabase: IAgentHostDatabase })._orchestratorDatabase.getSessionV2(child.toString()); assert.deepStrictEqual({ revisionAdvanced: firstSnapshot !== undefined && secondSnapshot?.sourceRevision === firstSnapshot.sourceRevision + 1, - projectedOrchestration: central?.orchestrationJson ? JSON.parse(central.orchestrationJson) : undefined, + projectedOrchestration: catalogDataOf(central)?._meta?.[SESSION_META_ORCHESTRATION_KEY], receiptPayload: secondSnapshot?.payload, persistedOrchestration: JSON.parse((await db.getMetadata(AH_META_ORCHESTRATION_DB_KEY)) ?? 'null'), }, { @@ -9057,7 +8961,7 @@ suite('AgentService (node dispatcher)', () => { }); assert.deepStrictEqual(result.status === 'available' ? { - source: result.request.source.orchestration, + source: result.request.data._meta?.[SESSION_META_ORCHESTRATION_KEY], legacy: JSON.parse(result.request.legacyMetadata[AH_META_ORCHESTRATION_DB_KEY]), } : result, { source: persisted, @@ -9092,9 +8996,9 @@ suite('AgentService (node dispatcher)', () => { }); assert.deepStrictEqual({ - live: central?.ehcliAdoptable, + live: readSessionEhcliAdoptable(catalogDataOf(central)?._meta), receiptPayload: liveSnapshot?.payload, - reconciliation: reconciliation.status === 'available' ? reconciliation.request.source.ehcliAdoptable : undefined, + reconciliation: reconciliation.status === 'available' ? readSessionEhcliAdoptable(reconciliation.request.data._meta) : undefined, }, { live: true, receiptPayload: undefined, @@ -9135,11 +9039,11 @@ suite('AgentService (node dispatcher)', () => { const central = await (localService as unknown as { _orchestratorDatabase: IAgentHostDatabase })._orchestratorDatabase.getSessionV2(session.toString()); assert.deepStrictEqual({ projectionVersion: snapshot?.projectionVersion, - projectedGit: central?.gitSummaryJson ? JSON.parse(central.gitSummaryJson) : undefined, + projectedGit: catalogDataOf(central)?._meta?.git, receiptPayload: snapshot?.payload, legacyGit: JSON.parse((await db.getMetadata(META_GIT_STATE)) ?? 'null'), }, { - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + projectionVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, projectedGit: liveGit, receiptPayload: undefined, legacyGit: liveGit, @@ -9173,7 +9077,7 @@ suite('AgentService (node dispatcher)', () => { }); assert.deepStrictEqual(result.status === 'available' ? { - source: result.request.source.git, + source: result.request.data._meta?.git, legacy: JSON.parse(result.request.legacyMetadata[META_GIT_STATE]), } : result, { source: persistedGit, @@ -9200,7 +9104,7 @@ suite('AgentService (node dispatcher)', () => { }); assert.deepStrictEqual(result.status === 'available' ? { - source: result.request.source.git, + source: result.request.data._meta?.git, legacy: result.request.legacyMetadata[META_GIT_STATE], } : result, { source: undefined, @@ -9236,7 +9140,7 @@ suite('AgentService (node dispatcher)', () => { const snapshot = await db.getCatalogSyncSnapshot(); const central = await (localService as unknown as { _orchestratorDatabase: IAgentHostDatabase })._orchestratorDatabase.getSessionV2(session.toString()); assert.deepStrictEqual({ - projectedGit: central?.gitSummaryJson ? JSON.parse(central.gitSummaryJson) : undefined, + projectedGit: catalogDataOf(central)?._meta?.git, receiptPayload: snapshot?.payload, legacyGit: JSON.parse((await db.getMetadata(META_GIT_STATE)) ?? 'null'), workspacelessSentinel: await db.getMetadata(AH_META_WORKSPACELESS_DB_KEY), @@ -9291,9 +9195,9 @@ suite('AgentService (node dispatcher)', () => { generationChanged: upgraded?.sessionGeneration !== oldSnapshot.sessionGeneration, state: upgraded?.state, writes: [writesAfterImport, catalogDatabase.sessionV2UpsertAttempts], - hashStable: imported !== undefined && reconciled?.sourceHash === imported.sourceHash, + hashStable: imported !== undefined && reconciled?.payloadHash === imported.payloadHash, }, { - projectionVersion: AGENT_HOST_CATALOG_PROJECTION_VERSION, + projectionVersion: AGENT_HOST_CATALOG_PAYLOAD_VERSION, sourceRevision: 0, generationChanged: true, state: 'acknowledged', @@ -9947,7 +9851,6 @@ suite('AgentService (node dispatcher)', () => { undefined, undefined, catalogDatabase, - 'central', )); const [restarted] = await restartedService.listSessions(); @@ -9956,7 +9859,7 @@ suite('AgentService (node dispatcher)', () => { isRead: ((restarted.status ?? SessionStatus.Idle) & SessionStatus.IsRead) !== 0, isArchived: ((restarted.status ?? SessionStatus.Idle) & SessionStatus.IsArchived) !== 0, artifacts: readSessionArtifacts(restarted._meta), - persistedArtifacts: persisted?.artifactsJson ? JSON.parse(persisted.artifactsJson) : undefined, + persistedArtifacts: catalogDataOf(persisted)?._meta?.[SESSION_META_ARTIFACTS_KEY], }, { title: 'Renamed', isRead: true, @@ -10771,6 +10674,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')); + localService.registerProvider(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 { @@ -12496,8 +12420,8 @@ suite('AgentService (node dispatcher)', () => { const afterDelete = await catalogDatabase.getSessionV2(session.toString()); assert.deepStrictEqual({ - afterCreate: afterCreate ? (JSON.parse(afterCreate.chatsJson) as Array<{ uri: string; order: number; kind: string; title?: string }>).map(chat => ({ uri: chat.uri, order: chat.order, kind: chat.kind, title: chat.title })) : undefined, - afterDelete: afterDelete ? (JSON.parse(afterDelete.chatsJson) as Array<{ uri: string; order: number; kind: string }>).map(chat => ({ uri: chat.uri, order: chat.order, kind: chat.kind })) : undefined, + 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, @@ -12667,7 +12591,7 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ firstRestore, - repairedCentral: repairedCentral ? (JSON.parse(repairedCentral.chatsJson) as Array<{ uri: string }>).map(chat => chat.uri) : undefined, + 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(), @@ -14010,11 +13934,11 @@ suite('AgentService (node dispatcher)', () => { const summaryTitleChange = await summaryTitleChanged.p; await timeout(0); const central = await catalogDatabase.getSessionV2(sessionUri); - const centralChats = central ? (JSON.parse(central.chatsJson) as Array<{ uri: string; title?: string; titleSource?: string }>).map(chat => ({ + const centralChats = catalogDataOf(central)?.chats.map(chat => ({ uri: chat.uri, - title: chat.title, + title: chat.summary, titleSource: chat.titleSource, - })) : undefined; + })); assert.deepStrictEqual({ singleChatResult, diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index 2f630e35bade8d..84764b2bd69c88 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -18,7 +18,6 @@ import { IAgentEditAttributionService, NullAgentEditAttributionService } from '. import { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { IAgentHostDatabase } from '../../node/agentHostDatabase.js'; -import type { AgentHostCatalogReadMode, IAgentHostCatalogShadowValidationReporter } from '../../node/agentHostCatalogShadowValidator.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from '../../node/agentHostFileMonitorService.js'; import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { AgentService } from '../../node/agentService.js'; @@ -59,9 +58,6 @@ export function createTestAgentService( hostLaunchKind = AgentHostLaunchKind.Unknown, storageResource?: URI, orchestratorDatabase?: IAgentHostDatabase, - catalogReadMode?: AgentHostCatalogReadMode, - catalogShadowReporter?: IAgentHostCatalogShadowValidationReporter, - catalogShadowConcurrency?: number, ): AgentService { const effectiveFileMonitorService = fileMonitorService ?? new AgentHostFileMonitorService(fileService, logService); const clientConnectionService = new AgentHostClientConnectionService(); @@ -92,9 +88,6 @@ export function createTestAgentService( hostLaunchKind, storageResource, orchestratorDatabase, - catalogReadMode, - catalogShadowReporter, - catalogShadowConcurrency, }; const foundationDisposables = new DisposableStore(); const foundation = createAgentServiceFoundation({ diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index b937a6c868ef8b..e678f18ac494a6 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, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Projection } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry } from '../../node/agentSessionRegistry.js'; class TestAgentHostDatabase implements IAgentHostDatabase { @@ -230,7 +230,8 @@ class TestAgentHostDatabase implements IAgentHostDatabase { async getSessionV2(): Promise { return undefined; } async listSessionsV2(): Promise { return []; } - async upsertSessionV2(_projection: IAgentHostDatabaseSessionV2Projection, _expectedSessionGeneration: string | undefined): Promise { return 'missingSession'; } + async listSessionsV2Receipts(): Promise { return []; } + async upsertSessionV2(_envelope: IAgentHostDatabaseSessionV2Envelope, _expectedSessionGeneration: string | undefined): Promise { return 'missingSession'; } async close(): Promise { } dispose(): void { } diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 872801fb526f85..1307943a935ed9 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -5219,12 +5219,14 @@ suite('AgentSideEffects', () => { // Persist a custom title in the DB await sessionDb.setMetadata('customTitle', 'My Custom Title'); + // The catalog is authoritative for the listing, so a title written + // straight to session.db surfaces once reconciliation folds it in. + await localService.whenCatalogReconciliationIdle(); + (localService as unknown as { _invalidateSessionList(): void })._invalidateSessionList(); 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/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index 0ced40c4bc66bd..ae201773a42ce4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -520,6 +520,195 @@ Sysroot/asset download `429: Too Many Requests`, network resets, etc. are infras --- +## Live compatibility + +The suites above run one build against itself. The **live-compatibility** suite +(`liveCompat/`) runs *different builds against the same profile*, which is the +only way to ask whether a change to persisted state can be shipped: whether an +old profile opens in the new build, whether a new profile still opens in an old +one, and whether either survives an unclean death. + +### What "black box" means here — and what it does not + +Every build is driven **only over AHP**. The suite never imports host internals +and never inspects the database or any other persisted file directly: if a +migration claim cannot be observed through the protocol, it is not asserted. +That is the property the compatibility claims rest on, since a client speaking +AHP is exactly what a shipped build has to satisfy. + +It does **not** mean the host is launched the way a user launches it. Each build +is started with `--enable-mock-agent` and driven through the scripted mock +provider, so **no model is ever contacted** and a run is deterministic and +tokenless. That is a test-only flag, and it is a deliberate trade: these +scenarios are about *persisted state surviving a version change*, which the +choice of provider does not affect, and pinning them to a real provider would +make every compatibility result depend on fixtures that drift for unrelated +reasons. + +So this suite is not provider-parity evidence. Whether a provider behaves +correctly is what the `providers/` E2E suites answer, against recorded CAPI +fixtures. This suite answers whether a profile written by one build is usable by +another, and it is the only suite that answers that. + +### Builds + +Four checkpoints, listed by `npm run agent-host-live-compat`: + +| Build | Source | +|---|---| +| `legacy` | oldest supported checkpoint | +| `predecessor` | the build immediately preceding the in-flight change | +| `intermediate` | an intermediate checkpoint, for multi-hop upgrades | +| `current` | your working tree | + +The three historical ones are materialized as **detached git worktrees** under a +cache root outside the repository (`$TMPDIR/vscode-agent-host-live-compat`, or +`AGENT_HOST_LIVE_COMPAT_CACHE`) and compiled there. Your repository is never +checked out, reset, stashed or cleaned; `current` is simply built in place. + +```sh +# Prepare every build. Does real work the first time; a no-op afterwards. +npm run agent-host-live-compat-prepare + +# Report readiness. Non-destructive: it never prepares, compiles or deletes. +npm run agent-host-live-compat-check +``` + +**Prerequisites.** Preparation needs the checkpoint commits present locally +(they are resolved in *this* repository, so a shallow clone will not have them) +and network access for two `npm install` runs per checkpoint. A checkpoint that +cannot be resolved is reported as not-ready with both fixes — fetch it, or +re-pin it — and never silently skipped. + +**Cost.** Measured on macOS, cold, for all three historical checkpoints: +**~1 m 36 s** and **2.9 GB each (8.8 GB total)**. Re-running preparation once +ready is a no-op (~0.07 s). + +That footprint is deliberate, and its shape is worth knowing before trying to +shrink it further. Preparation installs with `--ignore-scripts`, skipping the +repository-wide `postinstall` that would otherwise install every built-in +extension and the remote tree — **7.6 GB** per checkpoint, none of which the +Agent Host uses. It then rebuilds, explicitly, only the native modules the host +actually loads at run time (`@vscode/sqlite3`, which backs the very database +these scenarios migrate, `@vscode/fs-copyfile`, `@vscode/spdlog`, +`@parcel/watcher`, `node-pty`). + +That rebuild is not optional: skipping it produces builds that transpile +cleanly and then exit 1 on startup with `Cannot find module +'../build/Debug/vscode_fs.node'`. Preparation is therefore validated by running +the full matrix from a cold cache, not by checking that the entry point exists. + +Preparation is cached on the checkpoint commit plus a build-recipe version, +recorded in a marker under the cache root (**not** inside the worktree, so the +worktree stays clean and the dirty check keeps protecting real local edits). +A separate sentinel records that dependency installation *succeeded*, so an +interrupted install is redone rather than half-reused. Change the recipe and +stale output is rebuilt rather than silently reused; `--force` rebuilds +regardless, and also forces a rebuild of `current`, which is otherwise left +alone when it is already compiled. + +### Running the matrices + +```sh +npm run agent-host-live-compat-baselines # each build restarts against its own profile +npm run agent-host-live-compat-forward # legacy/predecessor/intermediate ▸ current +npm run agent-host-live-compat-backward # current ▸ older ▸ current round trips +npm run agent-host-live-compat-recovery # SIGKILL and restart + +npm run agent-host-live-compat-all # all four, in that order +npm run agent-host-live-compat-pr # the reduced subset CI runs on a PR +``` + +Three properties of a run are worth knowing, because they are what the aggregate +command exists to guarantee: + +- **Matrices run sequentially.** Each scenario forks real Agent Host processes + from separately compiled trees that share this machine's temp space and ports. + Overlapping them would make a failure attributable to contention rather than + to compatibility. +- **An unresolved checkpoint is a failure, never a skip.** A build that was + never prepared is reported as a failed row carrying the exact `--prepare` + command to run, and the process exits nonzero. A run that covered two of three + upgrades can never be mistaken for one that covered three. +- **Evidence is retained.** Every matrix writes a JSON summary under + `.build/agent-host-live-compat` (`--output-dir` to relocate), and a multi-matrix + run adds `run.json` aggregating them. Each scenario also keeps its diagnostics + directory — the home, the user-data directory (and so the host's own logs) and + the workspace, for every phase. These are never deleted: a compatibility + failure exists to be diagnosed, and that state is the diagnosis. + +The full sweep takes roughly a minute against prepared builds. + +### CI — not yet wired up, and why + +**There is no workflow for this suite yet.** It is run manually with the +commands above. Wiring it into CI is deliberately deferred rather than +forgotten, because a prerequisite is not met today. + +Two of the three historical checkpoints (`49f24d8`, `7453d67`) exist only on the +feature branch that introduced this suite. They are unreachable from the default +branch, from a fork, and from the shallow clones CI jobs use — so a scheduled or +fork-triggered job could not resolve them, and would either fail for a reason +that has nothing to do with compatibility or, worse, appear to pass while +silently covering less than it claims. The runner refuses to do the latter: an +unresolvable checkpoint is reported as a failed row that names both fixes +(fetch, or re-pin), never skipped. + +**Prerequisites, in order:** + +1. This work lands on the default branch. +2. Re-pin `legacy`, `predecessor` and `intermediate` in + `harness/agentHostLiveCompatBuilds.ts` to commits reachable from the default + branch. (`legacy`, `97ed7b5`, already is; the other two are not.) +3. Confirm a cold preparation on a clean CI runner — the numbers below were + measured locally on macOS, and the install step is the part most likely to + differ. +4. Then add the workflow. + +**Intended shape**, once those hold: the reduced subset (`--pr`) on pull +requests, and the full sweep on a schedule and on demand. The subset keeps the +*shape* of every claim — all four matrices still run — but only against +`predecessor`, the checkpoint an in-flight regression shows up against first; +pinning "oldest supported" is what the scheduled run is for. Summaries and +diagnostics should be uploaded as an artifact on success and failure alike. + +Two details such a job must get right, both learned the hard way: + +- Checkpoints are resolved as commits **in this repository**, so a shallow + checkout cannot see them; the job needs full history (`fetch-depth: 0`). +- The path filter should cover `src/vs/platform/agentHost/**`, + `scripts/test-agent-host-live-compat.ts`, `package.json` (the commands live + there, so a change to them changes what CI runs) and the workflow itself. + +Caching the prepared worktrees is an obvious further win but is **unproven** — +a restored worktree's git metadata lives in the main repository and is not part +of the archive. The runner detects and re-registers that case, but no cache +round trip has actually been exercised on a runner, so it should be measured +before being relied on. + +### What recovery does *not* cover + +The recovery matrix asserts **convergence** after an unclean kill — the session +is present exactly once and is describable — rather than "the last write +survived". The host advertises no durability acknowledgment and the catalogue +write is queued fire-and-forget, so a rename is readable long before it is +durable; asserting that it survives would encode a guarantee the host does not +make and would flake as a function of disk speed. Which shape was observed is +reported in `classificationCounts`, so the durability gap stays visible as data. + +Two boundaries are **deliberately out of black-box reach** and are routed to +scoped integration tests rather than faked with a plausible-looking E2E: + +| Boundary | Why it is integration-only | +|---|---| +| `torn-write-corruption` | A black-box AHP client cannot truncate host-owned files; doing so would violate the externality principle. | +| `pending-receipt-at-kill` | The write queue is internal and no durability acknowledgment is advertised, so the boundary cannot be observed or targeted from outside. | + +Both ship inside every recovery summary as `boundaries` and +`integrationProposals`, described precisely enough to be implemented without +re-deriving the analysis. A run's most misreadable property is its *scope*, so +what was deliberately not covered travels in the same artifact as what passed. + ## Relationship to the protocol suite `../protocol/` is **frozen**. Do not add tests there; add them here. diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts new file mode 100644 index 00000000000000..1c9d6c2183ee5b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Pure planning layer for cross-version ("live compatibility") Agent Host runs. + * + * A live-compat scenario drives one preserved user-data directory through + * several *builds* of the Agent Host: an old release, an intermediate one, and + * the build currently under development. This module owns the decisions — + * where a build lives, how it is identified, when it may be reused — without + * performing any filesystem or git work, so the rules can be unit tested + * without checking out or compiling anything. + * + * The externality principle still holds: nothing here knows anything about the + * agent host beyond the path of its server entry point. + */ + +import { join } from '../../../../../../base/common/path.js'; + +/** How the sources for a build are obtained. */ +export const enum AgentHostBuildSourceKind { + /** + * An immutable git ref (commit sha, tag). Materialized into a detached + * worktree under the cache root and built there, never in the repository + * the test runs from. + */ + Ref = 'ref', + /** + * The developer's current (possibly dirty) working tree. Used as-is: never + * checked out, reset, stashed, or otherwise mutated. + */ + WorkingTree = 'workingTree', +} + +/** A named build a live-compat scenario can run a phase against. */ +export interface IAgentHostBuildDescriptor { + /** Stable id used in scenario code, reporting and cache paths, e.g. `legacy`. */ + readonly id: string; + readonly source: AgentHostBuildSourceKind; + /** Immutable git ref; required for {@link AgentHostBuildSourceKind.Ref}, forbidden otherwise. */ + readonly ref?: string; + /** Human readable note surfaced in diagnostics. */ + readonly description?: string; +} + +export interface IAgentHostBuildPlanContext { + /** Absolute path of the repository the test runs from. Never mutated for ref builds. */ + readonly repoRoot: string; + /** Absolute path under which historical worktrees and their outputs are cached. */ + readonly cacheRoot: string; + /** + * The resolved commit sha for a {@link AgentHostBuildSourceKind.Ref} build. + * Planning is pure, so the caller resolves the ref and passes the result in. + */ + readonly resolvedCommit?: string; + /** + * Bumped whenever the build recipe changes in a way that invalidates + * previously cached outputs. + */ + readonly recipeVersion: string; +} + +/** Where a build lives and how to tell whether it is already usable. */ +export interface IAgentHostBuildPlan { + readonly id: string; + readonly source: AgentHostBuildSourceKind; + readonly ref?: string; + readonly resolvedCommit?: string; + readonly description?: string; + /** Root of the sources for this build (a cached worktree, or the repo itself). */ + readonly sourceRoot: string; + /** Absolute path of the compiled agent host server entry to launch. */ + readonly serverEntry: string; + /** + * Identity of the built output. Equal keys mean the cached build is still + * valid; `undefined` for the working tree, which is never cached because it + * changes under us by design. + */ + readonly cacheKey: string | undefined; + /** File recording {@link cacheKey} for a completed build; `undefined` when uncacheable. */ + readonly cacheMarkerPath: string | undefined; + /** Whether a git worktree must be materialized before building. */ + readonly requiresWorktree: boolean; +} + +/** Relative path of the compiled agent host server entry within a build output root. */ +export const AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH = join('out', 'vs', 'platform', 'agentHost', 'node', 'agentHostServerMain.js'); + +const BUILD_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const COMMIT_PATTERN = /^[0-9a-f]{40}$/; + +/** + * Validate a descriptor and resolve it to concrete paths and a cache identity. + * + * @throws when the descriptor is internally inconsistent, which is always a + * scenario authoring bug rather than an environment problem. + */ +export function planAgentHostBuild(descriptor: IAgentHostBuildDescriptor, context: IAgentHostBuildPlanContext): IAgentHostBuildPlan { + if (!BUILD_ID_PATTERN.test(descriptor.id)) { + throw new Error(`[agent-host-live-compat] invalid build id '${descriptor.id}': expected lowercase alphanumeric segments separated by '-'`); + } + + if (descriptor.source === AgentHostBuildSourceKind.WorkingTree) { + if (descriptor.ref !== undefined) { + throw new Error(`[agent-host-live-compat] build '${descriptor.id}' targets the working tree and must not declare a ref (got '${descriptor.ref}')`); + } + return { + id: descriptor.id, + source: descriptor.source, + description: descriptor.description, + sourceRoot: context.repoRoot, + serverEntry: join(context.repoRoot, AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH), + cacheKey: undefined, + cacheMarkerPath: undefined, + requiresWorktree: false, + }; + } + + if (!descriptor.ref) { + throw new Error(`[agent-host-live-compat] build '${descriptor.id}' targets a git ref but declares none`); + } + if (context.resolvedCommit !== undefined && !COMMIT_PATTERN.test(context.resolvedCommit)) { + throw new Error(`[agent-host-live-compat] build '${descriptor.id}' resolved to '${context.resolvedCommit}', which is not a full commit sha`); + } + + const sourceRoot = join(context.cacheRoot, 'builds', descriptor.id); + return { + id: descriptor.id, + source: descriptor.source, + ref: descriptor.ref, + resolvedCommit: context.resolvedCommit, + description: descriptor.description, + sourceRoot, + serverEntry: join(sourceRoot, AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH), + cacheKey: context.resolvedCommit === undefined ? undefined : buildCacheKey(context.resolvedCommit, context.recipeVersion), + cacheMarkerPath: join(sourceRoot, '.agent-host-live-compat-build.json'), + requiresWorktree: true, + }; +} + +function buildCacheKey(resolvedCommit: string, recipeVersion: string): string { + return `commit:${resolvedCommit}|recipe:${recipeVersion}`; +} + +/** Contents of a build's cache marker file. */ +export interface IAgentHostBuildCacheMarker { + readonly cacheKey: string; + readonly builtAt: string; +} + +export function serializeBuildCacheMarker(cacheKey: string, builtAt: string): string { + return `${JSON.stringify({ cacheKey, builtAt } satisfies IAgentHostBuildCacheMarker, undefined, '\t')}\n`; +} + +/** + * Whether a previously built output can be reused. Unreadable or malformed + * markers are treated as "not built" rather than as errors: a stale cache must + * never fail a run, it must only cost a rebuild. + */ +export function isBuildCacheUsable(plan: IAgentHostBuildPlan, markerContent: string | undefined): boolean { + if (plan.cacheKey === undefined || markerContent === undefined) { + return false; + } + try { + const marker = JSON.parse(markerContent) as Partial; + return marker.cacheKey === plan.cacheKey; + } catch { + return false; + } +} + +/** + * Explain why a planned build cannot be launched, in terms a developer can act + * on. Returns `undefined` when the build looks launchable. + */ +export function describeUnusableBuild(plan: IAgentHostBuildPlan, state: { readonly serverEntryExists: boolean; readonly cacheUsable: boolean }): string | undefined { + if (state.serverEntryExists && (state.cacheUsable || plan.cacheKey === undefined)) { + return undefined; + } + const lines = [`[agent-host-live-compat] build '${plan.id}' is not ready to launch.`]; + if (plan.description) { + lines.push(` ${plan.description}`); + } + if (!state.serverEntryExists) { + lines.push(` Missing compiled entry: ${plan.serverEntry}`); + } else { + lines.push(` Compiled output is stale for ${plan.ref ?? 'the working tree'}: ${plan.sourceRoot}`); + } + if (plan.source === AgentHostBuildSourceKind.WorkingTree) { + lines.push(' Compile the current working tree (for example `npm run transpile-client`) and re-run.'); + } else { + lines.push(` Prepare it with: node scripts/test-agent-host-live-compat.ts --prepare ${plan.id}`); + } + return lines.join('\n'); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatBuilds.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatBuilds.ts new file mode 100644 index 00000000000000..805a5085068f43 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatBuilds.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The named Agent Host builds live-compatibility scenarios run against. + * + * Scenarios refer to these by id only, so a checkpoint can be re-pinned to a + * newer commit without touching any scenario. Ids are ordered oldest-first. + */ + +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { AgentHostBuildSourceKind, type IAgentHostBuildDescriptor, type IAgentHostBuildPlanContext } from './agentHostBuildPlan.js'; + +/** + * Bump when the way a historical build is compiled changes in a manner that + * invalidates already-cached outputs. + */ +export const AGENT_HOST_LIVE_COMPAT_RECIPE_VERSION = '1'; + +export const enum AgentHostBuildId { + Legacy = 'legacy', + Predecessor = 'predecessor', + Intermediate = 'intermediate', + Current = 'current', +} + +export const agentHostLiveCompatBuilds: readonly IAgentHostBuildDescriptor[] = [ + { + id: AgentHostBuildId.Legacy, + source: AgentHostBuildSourceKind.Ref, + ref: '97ed7b57c6d9becb4fe386c59157eda016050d6a', + description: 'Oldest supported Agent Host build in the compatibility matrix.', + }, + { + id: AgentHostBuildId.Predecessor, + source: AgentHostBuildSourceKind.Ref, + ref: '49f24d87cd32d2a696e469d2c61fb8d0cada4cc9', + description: 'The build immediately preceding the in-flight changes.', + }, + { + id: AgentHostBuildId.Intermediate, + source: AgentHostBuildSourceKind.Ref, + ref: '7453d67fdcde27faba527d69a535ddd51b8d1afa', + description: 'Intermediate build used to exercise multi-hop upgrades.', + }, + { + id: AgentHostBuildId.Current, + source: AgentHostBuildSourceKind.WorkingTree, + description: 'The current working tree, built in place; never checked out or reset.', + }, +]; + +export function agentHostLiveCompatBuild(id: AgentHostBuildId | string): IAgentHostBuildDescriptor { + const descriptor = agentHostLiveCompatBuilds.find(build => build.id === id); + if (!descriptor) { + throw new Error(`[agent-host-live-compat] unknown build checkpoint '${id}'; known: ${agentHostLiveCompatBuilds.map(build => build.id).join(', ')}`); + } + return descriptor; +} + +/** + * Default cache root for materialized historical worktrees and their compiled + * output. Deliberately outside the repository so a stale cache can never be + * mistaken for repository content, and overridable for CI. + */ +export function agentHostLiveCompatCacheRoot(environment: Readonly> = process.env): string { + return environment['AGENT_HOST_LIVE_COMPAT_CACHE'] || join(tmpdir(), 'vscode-agent-host-live-compat'); +} + +/** + * Build the planning context for a checkpoint. `resolveCommit` is supplied by + * the caller (the preparation script resolves refs with git; tests can pass a + * fixed sha) so that planning itself stays pure. + */ +export function agentHostLiveCompatPlanContext( + descriptor: IAgentHostBuildDescriptor, + options: { readonly repoRoot: string; readonly cacheRoot?: string; readonly resolveCommit?: (ref: string) => string | undefined }, +): IAgentHostBuildPlanContext { + return { + repoRoot: options.repoRoot, + cacheRoot: options.cacheRoot ?? agentHostLiveCompatCacheRoot(), + resolvedCommit: descriptor.ref ? options.resolveCommit?.(descriptor.ref) : undefined, + recipeVersion: AGENT_HOST_LIVE_COMPAT_RECIPE_VERSION, + }; +} diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatHarness.test.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatHarness.test.ts new file mode 100644 index 00000000000000..90d30a554f4fb1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatHarness.test.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { join } from '../../../../../../base/common/path.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { + AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH, + AgentHostBuildSourceKind, + describeUnusableBuild, + isBuildCacheUsable, + planAgentHostBuild, + serializeBuildCacheMarker, + type IAgentHostBuildDescriptor, +} from './agentHostBuildPlan.js'; +import { + CrossVersionAgentHostTarget, + resolvePreparedBuild, + type IBuildFileSystem, +} from './crossVersionAgentHostTarget.js'; +import { agentHostLiveCompatBuild, agentHostLiveCompatBuilds, agentHostLiveCompatPlanContext } from './agentHostLiveCompatBuilds.js'; + +const COMMIT = '97ed7b57c6d9becb4fe386c59157eda016050d6a'; +const REPO_ROOT = join('/', 'repo'); +const CACHE_ROOT = join('/', 'cache'); + +function fileSystem(files: Readonly>): IBuildFileSystem { + return { + exists: path => files[path] !== undefined, + readText: path => (typeof files[path] === 'string' ? files[path] as string : undefined), + }; +} + +suite('Agent Host live-compat build planning', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const refBuild: IAgentHostBuildDescriptor = { id: 'legacy', source: AgentHostBuildSourceKind.Ref, ref: COMMIT }; + const workingTreeBuild: IAgentHostBuildDescriptor = { id: 'current', source: AgentHostBuildSourceKind.WorkingTree }; + const context = { repoRoot: REPO_ROOT, cacheRoot: CACHE_ROOT, resolvedCommit: COMMIT, recipeVersion: '1' }; + + test('a ref build is planned into the cache, a working-tree build into the repo', () => { + assert.deepStrictEqual( + [planAgentHostBuild(refBuild, context), planAgentHostBuild(workingTreeBuild, { ...context, resolvedCommit: undefined })], + [ + { + id: 'legacy', + source: AgentHostBuildSourceKind.Ref, + ref: COMMIT, + resolvedCommit: COMMIT, + description: undefined, + sourceRoot: join(CACHE_ROOT, 'builds', 'legacy'), + serverEntry: join(CACHE_ROOT, 'builds', 'legacy', AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH), + cacheKey: `commit:${COMMIT}|recipe:1`, + cacheMarkerPath: join(CACHE_ROOT, 'builds', 'legacy', '.agent-host-live-compat-build.json'), + requiresWorktree: true, + }, + { + id: 'current', + source: AgentHostBuildSourceKind.WorkingTree, + description: undefined, + sourceRoot: REPO_ROOT, + serverEntry: join(REPO_ROOT, AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH), + cacheKey: undefined, + cacheMarkerPath: undefined, + requiresWorktree: false, + }, + ], + ); + }); + + test('inconsistent descriptors are rejected', () => { + assert.throws(() => planAgentHostBuild({ id: 'Legacy Build', source: AgentHostBuildSourceKind.Ref, ref: COMMIT }, context), /invalid build id/); + assert.throws(() => planAgentHostBuild({ id: 'legacy', source: AgentHostBuildSourceKind.Ref }, context), /declares none/); + assert.throws(() => planAgentHostBuild({ id: 'current', source: AgentHostBuildSourceKind.WorkingTree, ref: COMMIT }, context), /must not declare a ref/); + assert.throws(() => planAgentHostBuild(refBuild, { ...context, resolvedCommit: 'HEAD' }), /not a full commit sha/); + }); + + test('cached output is reused only for a matching commit and recipe', () => { + const plan = planAgentHostBuild(refBuild, context); + const matching = serializeBuildCacheMarker(plan.cacheKey!, '2026-01-01T00:00:00.000Z'); + assert.deepStrictEqual( + [ + isBuildCacheUsable(plan, matching), + isBuildCacheUsable(plan, serializeBuildCacheMarker(`commit:${COMMIT}|recipe:2`, 'x')), + isBuildCacheUsable(plan, 'not json'), + isBuildCacheUsable(plan, undefined), + isBuildCacheUsable(planAgentHostBuild(workingTreeBuild, { ...context, resolvedCommit: undefined }), matching), + ], + [true, false, false, false, false], + ); + }); + + test('an unusable build explains what to do about it', () => { + const plan = planAgentHostBuild(refBuild, context); + assert.strictEqual(describeUnusableBuild(plan, { serverEntryExists: true, cacheUsable: true }), undefined); + assert.match(describeUnusableBuild(plan, { serverEntryExists: false, cacheUsable: false })!, /Missing compiled entry[\s\S]*--prepare legacy/); + assert.match(describeUnusableBuild(plan, { serverEntryExists: true, cacheUsable: false })!, /stale/); + const current = planAgentHostBuild(workingTreeBuild, { ...context, resolvedCommit: undefined }); + assert.match(describeUnusableBuild(current, { serverEntryExists: false, cacheUsable: false })!, /transpile-client/); + }); + + test('checkpoints are declared for the whole matrix and resolve to plans', () => { + assert.deepStrictEqual(agentHostLiveCompatBuilds.map(build => build.id), ['legacy', 'predecessor', 'intermediate', 'current']); + const descriptor = agentHostLiveCompatBuild('intermediate'); + const plan = planAgentHostBuild(descriptor, agentHostLiveCompatPlanContext(descriptor, { repoRoot: REPO_ROOT, cacheRoot: CACHE_ROOT, resolveCommit: () => COMMIT })); + assert.deepStrictEqual( + { sourceRoot: plan.sourceRoot, cacheKey: plan.cacheKey }, + { sourceRoot: join(CACHE_ROOT, 'builds', 'intermediate'), cacheKey: `commit:${COMMIT}|recipe:1` }, + ); + assert.throws(() => agentHostLiveCompatBuild('nope'), /unknown build checkpoint/); + }); +}); + +suite('Agent Host cross-version target', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const plan = planAgentHostBuild( + { id: 'legacy', source: AgentHostBuildSourceKind.Ref, ref: COMMIT }, + { repoRoot: REPO_ROOT, cacheRoot: CACHE_ROOT, resolvedCommit: COMMIT, recipeVersion: '1' }, + ); + + test('a prepared build resolves only when compiled output matches the checkpoint', () => { + const prepared = resolvePreparedBuild(plan, fileSystem({ + [plan.serverEntry]: true, + [plan.cacheMarkerPath!]: serializeBuildCacheMarker(plan.cacheKey!, 'x'), + })); + assert.deepStrictEqual(prepared, { id: 'legacy', serverEntry: plan.serverEntry, description: COMMIT }); + assert.throws(() => resolvePreparedBuild(plan, fileSystem({})), /not ready to launch/); + assert.throws(() => resolvePreparedBuild(plan, fileSystem({ [plan.serverEntry]: true })), /stale/); + }); + + test('the target switches build selection and reports launch history', () => { + const target = new CrossVersionAgentHostTarget([ + { id: 'legacy', serverEntry: join(CACHE_ROOT, 'builds', 'legacy', 'entry.js') }, + { id: 'current', serverEntry: join(REPO_ROOT, 'entry.js') }, + ]); + assert.deepStrictEqual( + { initial: target.currentBuildId, id: target.id, launched: target.launchedBuildIds }, + { initial: 'legacy', id: 'agent-host-live-compat:legacy', launched: [] }, + ); + target.useBuild('current'); + assert.strictEqual(target.currentBuildId, 'current'); + assert.throws(() => target.useBuild('missing'), /unknown build 'missing'/); + }); + + test('the target rejects an empty or ambiguous build set', () => { + assert.throws(() => new CrossVersionAgentHostTarget([]), /at least one build/); + assert.throws(() => new CrossVersionAgentHostTarget([{ id: 'a', serverEntry: '/a' }, { id: 'a', serverEntry: '/b' }]), /duplicate build id/); + assert.throws(() => new CrossVersionAgentHostTarget([{ id: 'a', serverEntry: '/a' }], 'b'), /unknown build 'b'/); + }); + + test('stopping with nothing launched is a no-op', async () => { + const target = new CrossVersionAgentHostTarget([{ id: 'a', serverEntry: '/a' }]); + await target.stopCurrentProcess(); + await target.stopCurrentProcess(); + assert.deepStrictEqual(target.launchedBuildIds, []); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/crossVersionAgentHostTarget.ts b/src/vs/platform/agentHost/test/node/e2e/harness/crossVersionAgentHostTarget.ts new file mode 100644 index 00000000000000..43be4943d43d15 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/harness/crossVersionAgentHostTarget.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Cross-version ("live compatibility") Agent Host targets. + * + * A live-compat scenario runs several *phases* against one preserved isolated + * user-data directory, switching the Agent Host **build** between phases: + * + * ```ts + * const target = new CrossVersionAgentHostTarget(builds); + * target.useBuild('legacy'); // phase 1 runs on the historical build + * // ... drive AHP ... + * target.useBuild('current'); // phase 2 relaunches on the current build + * await harness.restart(); // … same homeDir/userDataDir/replay proxy + * ``` + * + * Everything else is unchanged: assertions stay over AHP, and model traffic + * still goes through the same `CapiReplayProxy` instance, so replay remains + * strict and host-only tests still hard-fail on an unexpected model request. + * + * This file only *launches* builds. Materializing and compiling them is the + * job of `scripts/test-agent-host-live-compat.ts`; a build that has not been + * prepared produces an actionable error here rather than a module-not-found + * crash inside a forked child. + */ + +import { existsSync, readFileSync } from 'fs'; +import { startRealServer, stopServer, type IServerHandle } from '../../serverIntegrationTestHelpers.js'; +import { + AgentHostBuildSourceKind, + describeUnusableBuild, + isBuildCacheUsable, + planAgentHostBuild, + type IAgentHostBuildDescriptor, + type IAgentHostBuildPlan, + type IAgentHostBuildPlanContext, +} from './agentHostBuildPlan.js'; +import type { IAgentHostTarget, IAgentHostTargetLaunchOptions } from './agentHostTarget.js'; + +/** + * A single prepared build the suite can launch. Produced by + * {@link resolvePreparedBuild} from a descriptor, or constructed directly in + * tests that want to exercise the target without compiling anything. + */ +export interface IPreparedAgentHostBuild { + readonly id: string; + /** Absolute path of the compiled agent host server entry to fork. */ + readonly serverEntry: string; + /** Human readable provenance for diagnostics, e.g. a commit sha. */ + readonly description?: string; +} + +/** Filesystem probes the resolver needs; injectable so the rules are testable. */ +export interface IBuildFileSystem { + readonly exists: (path: string) => boolean; + readonly readText: (path: string) => string | undefined; +} + +export const realBuildFileSystem: IBuildFileSystem = { + exists: path => existsSync(path), + readText: path => { + try { + return readFileSync(path, 'utf8'); + } catch { + return undefined; + } + }, +}; + +/** + * Turn a planned build into a launchable one, or explain precisely what is + * missing. Never builds anything: preparation is an explicit, scriptable step. + */ +export function resolvePreparedBuild(plan: IAgentHostBuildPlan, fileSystem: IBuildFileSystem = realBuildFileSystem): IPreparedAgentHostBuild { + const cacheUsable = plan.cacheMarkerPath === undefined + ? plan.source === AgentHostBuildSourceKind.WorkingTree + : isBuildCacheUsable(plan, fileSystem.readText(plan.cacheMarkerPath)); + const problem = describeUnusableBuild(plan, { serverEntryExists: fileSystem.exists(plan.serverEntry), cacheUsable }); + if (problem) { + throw new Error(problem); + } + return { + id: plan.id, + serverEntry: plan.serverEntry, + description: plan.description ?? plan.resolvedCommit ?? plan.ref, + }; +} + +export function resolvePreparedBuilds( + descriptors: readonly IAgentHostBuildDescriptor[], + context: (descriptor: IAgentHostBuildDescriptor) => IAgentHostBuildPlanContext, + fileSystem: IBuildFileSystem = realBuildFileSystem, +): readonly IPreparedAgentHostBuild[] { + return descriptors.map(descriptor => resolvePreparedBuild(planAgentHostBuild(descriptor, context(descriptor)), fileSystem)); +} + +/** + * An {@link IAgentHostTarget} whose underlying build can be switched between + * phases of a scenario. Launching always goes through the same code path as + * the default target, so the persistent dirs and the replay proxy handed in by + * the harness are honored identically on every build. + */ +export class CrossVersionAgentHostTarget implements IAgentHostTarget { + + private readonly _builds = new Map(); + private _current: IPreparedAgentHostBuild; + private _lastLaunched: IServerHandle | undefined; + private readonly _launchedBuildIds: string[] = []; + + constructor(builds: readonly IPreparedAgentHostBuild[], initialBuildId?: string) { + if (builds.length === 0) { + throw new Error('[agent-host-live-compat] a cross-version target needs at least one build'); + } + for (const build of builds) { + if (this._builds.has(build.id)) { + throw new Error(`[agent-host-live-compat] duplicate build id '${build.id}'`); + } + this._builds.set(build.id, build); + } + this._current = initialBuildId ? this._lookup(initialBuildId) : builds[0]; + } + + get id(): string { + return `agent-host-live-compat:${this._current.id}`; + } + + /** The build id the next launch will use. */ + get currentBuildId(): string { + return this._current.id; + } + + /** Build ids actually launched so far, in order. Useful for phase assertions. */ + get launchedBuildIds(): readonly string[] { + return this._launchedBuildIds; + } + + /** + * Select the build subsequent launches use. The caller still drives the + * relaunch (typically `harness.restart()`), which is what preserves the + * user-data directory and the replay stream across the switch. + */ + useBuild(buildId: string): void { + this._current = this._lookup(buildId); + } + + async launch(options: IAgentHostTargetLaunchOptions): Promise { + // Switching builds against a shared user-data directory is only safe once + // the previous process has fully exited and released its state. + await this.stopCurrentProcess(); + const build = this._current; + const server = await startRealServer({ + serverEntry: build.serverEntry, + homeDir: options.homeDir, + userDataDir: options.userDataDir, + codexHomeDir: options.codexHomeDir, + capiReplay: options.capiReplay, + existingCapiReplay: options.existingCapiReplay, + claudeSdkRoot: options.claudeSdkRoot, + codexSdkRoot: options.codexSdkRoot, + logLevel: options.logLevel, + env: options.env, + }); + this._lastLaunched = server; + this._launchedBuildIds.push(build.id); + return server; + } + + /** + * Await full shutdown of the process this target last launched. Safe to + * call when nothing is running, and idempotent. + */ + async stopCurrentProcess(): Promise { + const previous = this._lastLaunched; + this._lastLaunched = undefined; + if (previous) { + await stopServer(previous); + } + } + + private _lookup(buildId: string): IPreparedAgentHostBuild { + const build = this._builds.get(buildId); + if (!build) { + throw new Error(`[agent-host-live-compat] unknown build '${buildId}'; prepared builds: ${[...this._builds.keys()].join(', ')}`); + } + return build; + } +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.test.ts new file mode 100644 index 00000000000000..3e0a87401f8830 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.test.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + compareProtocolVersions, + createAgentHostCapabilityAdapter, + LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, +} from './agentHostLiveCompatCapabilities.js'; +import type { AgentProviderCapabilities } from './agentHostLiveCompatProtocol.js'; + +function adapterFor(protocolVersion: string, providers: Readonly> = {}) { + return createAgentHostCapabilityAdapter({ + protocolVersion, + providerCapabilities: new Map(Object.entries(providers)), + }); +} + +suite('Agent Host live-compat capability adapter', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('capabilities come from what the build advertises, not from its checkpoint', () => { + const legacy = adapterFor('0.8.0', { mock: {}, copilotcli: { multipleChats: { fork: true } } }); + const current = adapterFor('1.0.0', { mock: {}, copilotcli: { multipleChats: { fork: true } } }); + assert.deepStrictEqual( + [legacy, current].map(adapter => ({ + protocolVersion: adapter.protocolVersion, + rename: adapter.supportsSessionRename, + peerOnMock: adapter.supportsPeerChats('mock'), + peerOnCopilot: adapter.supportsPeerChats('copilotcli'), + peerOnUnknown: adapter.supportsPeerChats('not-registered'), + })), + [ + { protocolVersion: '0.8.0', rename: true, peerOnMock: false, peerOnCopilot: true, peerOnUnknown: false }, + { protocolVersion: '1.0.0', rename: true, peerOnMock: false, peerOnCopilot: true, peerOnUnknown: false }, + ], + ); + }); + + test('a build older than the whole matrix degrades rename instead of asserting on it', () => { + assert.strictEqual(adapterFor('0.4.0').supportsSessionRename, false); + assert.strictEqual(adapterFor('0.5.1').supportsSessionRename, true); + }); + + test('the offered version list is ordered newest-first and covers every checkpoint in the matrix', () => { + const ordered = [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS] + .every((version, index, all) => index === 0 || compareProtocolVersions(all[index - 1], version) > 0); + assert.deepStrictEqual( + { + ordered, + // The versions the four prepared builds actually negotiate today. + offersLegacy: LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS.includes('0.8.0'), + offersCurrent: LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS.includes('1.0.0'), + }, + { ordered: true, offersLegacy: true, offersCurrent: true }, + ); + }); + + test('protocol versions compare by precedence, and malformed input is rejected', () => { + assert.deepStrictEqual( + [ + Math.sign(compareProtocolVersions('1.0.0', '0.8.0')), + Math.sign(compareProtocolVersions('0.8.0', '1.0.0')), + Math.sign(compareProtocolVersions('0.8.0', '0.8.0')), + Math.sign(compareProtocolVersions('0.10.0', '0.9.0')), + Math.sign(compareProtocolVersions('0.8.2', '0.8.10')), + ], + [1, -1, 0, 1, -1], + ); + assert.throws(() => compareProtocolVersions('1.0', '1.0.0'), /not a protocol version/); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.ts new file mode 100644 index 00000000000000..1450123d753a7c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The external capability adapter for live-compatibility scenarios. + * + * A live-compat scenario runs the *same* script against Agent Host builds that + * are months apart, so the script inevitably meets contract evolution: an older + * build negotiates an older protocol version, and a feature the current build + * has may not exist there at all. + * + * The rule this file exists to enforce is that such differences are resolved + * **once, externally, from what the build advertises over AHP** — never with + * `if (buildId === 'legacy')` branches sprinkled through scenario bodies. A + * scenario asks the adapter a question ("can I create a peer chat here?") and + * the adapter answers from the handshake and the root snapshot, exactly as any + * real AHP client would have to. + * + * Consequently nothing here reads the repository, imports host internals, or + * consults the checkpoint id. Adding a fifth checkpoint must require no change + * to this file; adding a *capability* is the only reason to touch it. + */ + +import type { AgentProviderCapabilities } from './agentHostLiveCompatProtocol.js'; + +/** + * Every protocol version this suite is willing to negotiate, most preferred + * first. + * + * Deliberately a literal list rather than an import of the working tree's + * `SUPPORTED_PROTOCOL_VERSIONS`: the suite plays the role of a *client* that + * must interoperate with all four builds, and the oldest of them predates + * entries the current tree advertises. Pinning the list here keeps the offer + * stable when the working tree's own list moves. + */ +export const LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '1.0.0', + '0.8.0', + '0.7.0', + '0.6.0', + '0.5.2', + '0.5.1', +]); + +/** What a build advertised, as observed over AHP. */ +export interface IAgentHostAdvertisedSurface { + /** Version the `initialize` handshake settled on. */ + readonly protocolVersion: string; + /** Provider capabilities from the root snapshot, keyed by provider id. */ + readonly providerCapabilities: ReadonlyMap; +} + +/** + * The questions a scenario is allowed to ask about the build it is driving. + * + * Each is derived from {@link IAgentHostAdvertisedSurface}, so a scenario's + * behavior is a function of the advertised contract rather than of which + * checkpoint happens to be running. + */ +export interface IAgentHostCapabilityAdapter { + readonly protocolVersion: string; + /** Whether `session/titleChanged` may be dispatched for a readable rename. */ + readonly supportsSessionRename: boolean; + /** + * Whether `createChat` may be called against `provider`. False when the + * provider does not advertise `multipleChats`, in which case a peer-chat + * step must be skipped rather than expected to fail. + */ + supportsPeerChats(provider: string): boolean; +} + +/** + * Minimum negotiated protocol version that carries `session/titleChanged` as a + * client-dispatchable action. Every checkpoint in the matrix is at or above it + * today; the check exists so a future older checkpoint degrades into a skipped + * step with a stated reason instead of a mystery assertion failure. + */ +const MIN_PROTOCOL_VERSION_FOR_RENAME = '0.5.1'; + +export function createAgentHostCapabilityAdapter(surface: IAgentHostAdvertisedSurface): IAgentHostCapabilityAdapter { + return { + protocolVersion: surface.protocolVersion, + supportsSessionRename: compareProtocolVersions(surface.protocolVersion, MIN_PROTOCOL_VERSION_FOR_RENAME) >= 0, + supportsPeerChats: provider => surface.providerCapabilities.get(provider)?.multipleChats !== undefined, + }; +} + +/** + * Compares two `MAJOR.MINOR.PATCH` protocol versions. Returns a negative + * number when `left` is older, zero when equal, positive when newer. + * + * A local implementation rather than an import: the working tree's comparator + * is part of the code under test, and a compatibility suite that borrowed it + * would stop being able to detect a regression in it. + */ +export function compareProtocolVersions(left: string, right: string): number { + const leftParts = parseProtocolVersion(left); + const rightParts = parseProtocolVersion(right); + for (let index = 0; index < 3; index++) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] - rightParts[index]; + } + } + return 0; +} + +function parseProtocolVersion(version: string): readonly [number, number, number] { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (!match) { + throw new Error(`[agent-host-live-compat] not a protocol version: '${version}'`); + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatClient.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatClient.ts new file mode 100644 index 00000000000000..3c4d1bccd12f15 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatClient.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * A minimal AHP client for live-compatibility scenarios. + * + * The E2E suite's `TestProtocolClient` is the richer client — snapshots, reverse + * requests, notification waiters — but it is built for a Mocha test process and + * transitively pulls in the snapshot module, which installs `setup`/`teardown` + * at import time. Live-compat baselines are driven from a plain `node` script, + * so importing it there would fail before a single build was launched. + * + * That constraint turns out to be the right shape anyway. The suite's governing + * principle is that the implementation is reached *only* over the Agent Host + * Protocol on a WebSocket; this client is that seam and nothing else. It speaks + * JSON-RPC 2.0, serves no reverse requests, and knows nothing about any host + * type — which is precisely the position a real third-party client is in when + * it meets a build from six months ago. + * + * Reverse requests are answered with a method-not-found error rather than + * ignored: a baseline never asks the host to touch client-side files, so a + * reverse request arriving at all is a signal worth surfacing, and leaving it + * unanswered would instead hang the host until its own timeout. + */ + +import { WebSocket } from 'ws'; + +/** JSON-RPC error surfaced by the host. */ +export class LiveCompatProtocolError extends Error { + constructor(readonly code: number, message: string) { + super(message); + } +} + +const JSON_RPC_METHOD_NOT_FOUND = -32601; + +interface IPendingCall { + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + readonly timer: ReturnType; +} + +export class LiveCompatAhpClient { + private readonly _socket: WebSocket; + private readonly _pending = new Map(); + private _nextId = 1; + private _closed = false; + + constructor(port: number) { + this._socket = new WebSocket(`ws://127.0.0.1:${port}`); + } + + connect(): Promise { + return new Promise((resolve, reject) => { + this._socket.on('error', reject); + this._socket.on('open', () => { + this._socket.on('message', data => this._receive(data.toString())); + // A socket that drops mid-scenario must fail the outstanding call + // rather than let it sit until the per-call timeout. + this._socket.on('close', () => this._failAllPending(new Error('[agent-host-live-compat] the host closed the connection'))); + resolve(); + }); + }); + } + + /** Send a JSON-RPC request and await its response. */ + call(method: string, params: unknown, timeoutMs: number): Promise { + if (this._closed) { + return Promise.reject(new Error(`[agent-host-live-compat] '${method}' on a closed connection`)); + } + const id = this._nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pending.delete(id); + reject(new Error(`[agent-host-live-compat] timed out after ${timeoutMs}ms waiting for '${method}'`)); + }, timeoutMs); + this._pending.set(id, { resolve: value => resolve(value as T), reject, timer }); + try { + this._socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + } catch (error) { + this._pending.delete(id); + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + /** Send a fire-and-forget JSON-RPC notification. */ + notify(method: string, params: unknown): void { + this._socket.send(JSON.stringify({ jsonrpc: '2.0', method, params })); + } + + close(): void { + if (this._closed) { + return; + } + this._closed = true; + this._failAllPending(new Error('[agent-host-live-compat] the client closed the connection')); + this._socket.close(); + } + + private _receive(text: string): void { + const message = JSON.parse(text) as { + id?: number; + method?: string; + result?: unknown; + error?: { code: number; message: string }; + }; + if (message.id !== undefined && message.method !== undefined) { + this._socket.send(JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + error: { code: JSON_RPC_METHOD_NOT_FOUND, message: `[agent-host-live-compat] reverse request '${message.method}' is not served by the baseline client` }, + })); + return; + } + if (message.id === undefined) { + // A server notification. Baselines assert on readbacks, not on the + // notification stream, so there is nothing to accumulate. + return; + } + const pending = this._pending.get(message.id); + if (!pending) { + return; + } + this._pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject(new LiveCompatProtocolError(message.error.code, message.error.message)); + } else { + pending.resolve(message.result); + } + } + + private _failAllPending(error: Error): void { + for (const [id, pending] of this._pending) { + this._pending.delete(id); + clearTimeout(pending.timer); + pending.reject(error); + } + } +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatMatrix.ts new file mode 100644 index 00000000000000..24500f3e3cc912 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatMatrix.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Executes live-compatibility scenarios across the prepared build matrix and + * summarizes the outcome. + * + * Two rules shape this file: + * + * - **A build is never silently skipped.** A checkpoint that cannot even be + * resolved is reported as a failed entry carrying the resolver's own + * explanation, so a run that covered three of four builds can never be + * mistaken for a run that covered four. + * - **Builds run sequentially.** Each scenario forks a real Agent Host and, for + * the historical checkpoints, that process is a different compiled tree + * sharing this machine's temp space. Serializing keeps a failure attributable + * to one build instead of to contention between them. + */ + +import { existsSync, readFileSync } from 'fs'; +import { agentHostLiveCompatBuild, agentHostLiveCompatPlanContext, type AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; +import { AgentHostBuildSourceKind, describeUnusableBuild, isBuildCacheUsable, planAgentHostBuild } from '../harness/agentHostBuildPlan.js'; +import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; +import { runSameBuildRestartBaseline, type ILiveCompatScenarioResult } from './sameBuildRestartBaseline.js'; + +export interface ILiveCompatMatrixOptions { + readonly repoRoot: string; + /** Resolves a checkpoint ref to a full commit sha; supplied by the caller. */ + readonly resolveCommit: (ref: string) => string | undefined; + readonly cacheRoot?: string; + readonly diagnosticsRoot?: string; +} + +/** Aggregate outcome of one live-compat run. */ +export interface ILiveCompatMatrixSummary { + readonly suite: string; + readonly startedAt: string; + readonly durationMs: number; + readonly outcome: 'passed' | 'failed'; + readonly results: readonly ILiveCompatScenarioResult[]; +} + +/** + * Run the same-build restart baseline for each requested checkpoint, in order. + */ +export async function runSameBuildRestartBaselines( + buildIds: readonly (AgentHostBuildId | string)[], + options: ILiveCompatMatrixOptions, +): Promise { + const startedAt = Date.now(); + const results: ILiveCompatScenarioResult[] = []; + for (const buildId of buildIds) { + results.push(await runOne(buildId, options)); + } + return { + suite: 'agent-host-live-compat/same-build-restart-baseline', + startedAt: new Date(startedAt).toISOString(), + durationMs: Date.now() - startedAt, + outcome: results.every(result => result.outcome === 'passed') ? 'passed' : 'failed', + results, + }; +} + +async function runOne(buildId: AgentHostBuildId | string, options: ILiveCompatMatrixOptions): Promise { + const startedAt = Date.now(); + let prepared: IPreparedAgentHostBuild; + try { + prepared = resolveBuild(buildId, options); + } catch (error) { + // Resolution failure is a real, reportable result — the whole point of + // requirement "no silent skips" — and carries the resolver's actionable + // message (which names the exact `--prepare` command to run). + return { + scenario: 'same-build-restart-baseline', + build: String(buildId), + outcome: 'failed', + durationMs: Date.now() - startedAt, + steps: [{ name: 'resolve-build', outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }], + diagnosticsPath: '', + error: messageOf(error), + }; + } + return runSameBuildRestartBaseline(prepared, { diagnosticsRoot: options.diagnosticsRoot }); +} + +/** + * Resolve a checkpoint into a launchable build, or explain what is missing. + * + * This repeats the few lines of `resolvePreparedBuild` rather than calling it, + * for an import-graph reason worth stating: that function lives in + * `crossVersionAgentHostTarget.ts`, which imports the Mocha-oriented server + * helper and therefore cannot be loaded from a plain `node` process. The rules + * themselves are not duplicated — `isBuildCacheUsable` and + * `describeUnusableBuild` remain the single source of truth for what makes a + * build usable and what to tell the developer about it. + */ +export function resolveBuild(buildId: AgentHostBuildId | string, options: ILiveCompatMatrixOptions): IPreparedAgentHostBuild { + const descriptor = agentHostLiveCompatBuild(buildId); + const plan = planAgentHostBuild(descriptor, agentHostLiveCompatPlanContext(descriptor, { + repoRoot: options.repoRoot, + cacheRoot: options.cacheRoot, + resolveCommit: options.resolveCommit, + })); + const cacheUsable = plan.cacheMarkerPath === undefined + ? plan.source === AgentHostBuildSourceKind.WorkingTree + : isBuildCacheUsable(plan, readTextOrUndefined(plan.cacheMarkerPath)); + const problem = describeUnusableBuild(plan, { serverEntryExists: existsSync(plan.serverEntry), cacheUsable }); + if (problem) { + throw new Error(problem); + } + return { + id: plan.id, + serverEntry: plan.serverEntry, + description: plan.description ?? plan.resolvedCommit ?? plan.ref, + }; +} + +function readTextOrUndefined(path: string): string | undefined { + try { + return readFileSync(path, 'utf8'); + } catch { + return undefined; + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatProtocol.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatProtocol.ts new file mode 100644 index 00000000000000..ae05e0d180c920 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatProtocol.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The wire shapes live-compat scenarios read off AHP. + * + * These are deliberately **not** imports of the working tree's protocol types. + * A scenario here drives four builds at once, and the working tree's types + * describe only the newest of them: typing an older build's payload with them + * would quietly promise fields that build never sends. Worse, the protocol + * types are part of the code under test, so a compatibility suite that + * borrowed them could not detect a breaking change to them. + * + * So each interface below is a hand-written, *narrow* description of exactly + * the fields the suite asserts on, with everything optional that any build in + * the matrix may omit. Widening one is a deliberate act that says "the suite + * now depends on this field being present on every supported build". + */ + +/** `initialize` result, narrowed to the fields the suite reads. */ +export interface ILiveCompatInitializeResult { + readonly protocolVersion: string; +} + +/** One entry of `listSessions`, narrowed to durable identity and metadata. */ +export interface ILiveCompatSessionListItem { + readonly resource: string; + readonly provider?: string; + readonly title?: string; +} + +/** `listSessions` result. */ +export interface ILiveCompatSessionList { + readonly items?: readonly ILiveCompatSessionListItem[]; +} + +/** `subscribe` result carrying a channel snapshot. */ +export interface ILiveCompatSubscribeResult { + readonly snapshot?: { readonly state?: ILiveCompatChannelState }; +} + +/** Union of the session/root state fields the suite reads. */ +export interface ILiveCompatChannelState { + readonly title?: string; + readonly chats?: readonly { readonly resource: string }[]; + readonly agents?: readonly ILiveCompatAgentDescriptor[]; +} + +/** A provider as advertised on the root channel. */ +export interface ILiveCompatAgentDescriptor { + readonly provider: string; + readonly capabilities?: AgentProviderCapabilities; +} + +/** + * Provider capabilities the adapter interprets. Presence is the signal; the + * inner shape is irrelevant to every question the suite asks, so it is left + * unmodelled rather than mirrored inaccurately across four builds. + */ +export interface AgentProviderCapabilities { + readonly multipleChats?: object; +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatServer.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatServer.ts new file mode 100644 index 00000000000000..934c1938bf668e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatServer.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Launching an Agent Host build for a live-compatibility scenario. + * + * This is a separate launcher from `startRealServer` on purpose. That helper + * exists to stand up a *bundled provider* against the record/replay proxy, and + * carries the whole apparatus that goes with it: a mock CAPI upstream, minted + * Copilot tokens, SDK-root overrides, coverage plumbing. A restart baseline + * needs none of it — it must not contact a model at all — and inheriting that + * apparatus would make the baseline's result depend on fixture state that has + * nothing to do with whether a build can reopen its own profile. + * + * What it keeps is the part that matters for compatibility: the build's server + * entry is forked as a real process, isolated onto the supplied home and + * user-data directories, and reached only over the socket it advertises. The + * scripted mock provider is enabled through the same `--enable-mock-agent` + * flag every checkpoint in the matrix already supports. + */ + +import { fork, type ChildProcess } from 'child_process'; +import { join } from '../../../../../../base/common/path.js'; + +/** A launched build: the forked process and the port it advertised. */ +export interface ILiveCompatServerHandle { + readonly process: ChildProcess; + readonly port: number; +} + +export interface ILiveCompatLaunchOptions { + /** Absolute path of the compiled `agentHostServerMain.js` to fork. */ + readonly serverEntry: string; + /** Home directory the build must confine provider configuration to. */ + readonly homeDir: string; + /** User-data directory the build must confine its own state to. */ + readonly userDataDir: string; + /** Extra environment for the child process. */ + readonly env?: Readonly>; +} + +const STARTUP_TIMEOUT_MS = 60_000; +const SHUTDOWN_TIMEOUT_MS = 30_000; + +/** + * Fork a build's server and resolve once it advertises its port. + * + * The child is started on an ephemeral port (`--port 0`) so several builds can + * be exercised without coordinating a port range, and without a connection + * token because the socket never leaves the loopback interface. + */ +export function startLiveCompatServer(options: ILiveCompatLaunchOptions): Promise { + return new Promise((resolve, reject) => { + let child: ChildProcess; + try { + child = fork(options.serverEntry, [ + '--port', '0', + '--without-connection-token', + '--enable-mock-agent', + '--user-data-dir', options.userDataDir, + // The host's own logs are the primary diagnostic when a baseline + // fails, and they are written under the retained user-data dir. + '--log', 'trace', + ], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: isolatedEnvironment(options), + }); + } catch (error) { + reject(error); + return; + } + + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`[agent-host-live-compat] ${options.serverEntry} did not become ready within ${STARTUP_TIMEOUT_MS}ms`)); + }, STARTUP_TIMEOUT_MS); + + const settleWith = (outcome: () => void): void => { + clearTimeout(timer); + child.stdout?.removeAllListeners('data'); + outcome(); + }; + + child.stdout?.on('data', (data: Buffer) => { + const match = /READY:(\d+)/.exec(data.toString()); + if (match) { + settleWith(() => resolve({ process: child, port: Number(match[1]) })); + } + }); + // Swallowed deliberately: the child's diagnostics belong in its log file + // under the retained user-data directory, and the integration runner + // fails a test on unexpected console output. + child.stderr?.on('data', () => { }); + child.on('error', error => settleWith(() => reject(error))); + child.on('exit', code => settleWith(() => reject(new Error(`[agent-host-live-compat] ${options.serverEntry} exited with code ${code} before becoming ready`)))); + }); +} + +/** + * Confine the build to the scenario's directories. + * + * Ambient provider configuration is cleared rather than merely overridden: a + * developer's real `CLAUDE_CONFIG_DIR` or `CODEX_HOME` would otherwise leak + * local sessions into a run whose entire subject is which sessions survive. + */ +function isolatedEnvironment(options: ILiveCompatLaunchOptions): NodeJS.ProcessEnv { + return { + ...process.env, + HOME: options.homeDir, + USERPROFILE: options.homeDir, + XDG_CONFIG_HOME: join(options.homeDir, '.config'), + XDG_DATA_HOME: join(options.homeDir, '.local', 'share'), + CLAUDE_CONFIG_DIR: join(options.homeDir, '.claude'), + CODEX_HOME: join(options.homeDir, '.codex'), + COPILOT_HOME: join(options.homeDir, '.copilot'), + ...options.env, + }; +} + +/** + * Stop a launched build and wait for the process to actually exit. + * + * Awaiting the exit is the load-bearing part: the next phase reuses the same + * user-data directory, and a still-running predecessor would hold the state it + * is supposed to have handed over — turning a persistence result into a race. + * Shutdown is requested by closing stdin (the host's own signal), and escalated + * to a kill only if the process overstays, so a build that hangs on shutdown + * still yields a result rather than stalling the matrix. + */ +export async function stopLiveCompatServer(server: ILiveCompatServerHandle | undefined): Promise { + const child = server?.process; + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + const exited = new Promise(resolve => child.once('exit', () => resolve())); + child.stdin?.end(); + let timer: ReturnType | undefined; + const timedOut = new Promise<'timeout'>(resolve => { + timer = setTimeout(() => resolve('timeout'), SHUTDOWN_TIMEOUT_MS); + }); + try { + if (await Promise.race([exited.then(() => 'exited' as const), timedOut]) === 'timeout') { + child.kill('SIGKILL'); + await exited; + } + } finally { + clearTimeout(timer); + } +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.test.ts new file mode 100644 index 00000000000000..bc7b6372ef76f3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.test.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { describeIdentityMismatch } from './backwardCompatibilityMatrix.js'; +import { BACKWARD_COMPAT_OLDER_BUILDS } from './runBackwardCompatibilityMatrix.js'; + +const A = 'mock:/session-a'; +const B = 'mock:/session-b'; + +suite('Agent Host backward-compat identity rule', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('a listing that matches the expectation exactly is accepted', () => { + assert.strictEqual( + describeIdentityMismatch( + [{ resource: A, title: 'from older build' }, { resource: B, title: 'also older' }], + [{ resource: A, title: 'from older build' }, { resource: B, title: 'also older' }], + ), + undefined, + ); + }); + + test('order is not identity: the same set listed in reverse still matches', () => { + assert.strictEqual( + describeIdentityMismatch([{ resource: B }, { resource: A }], [{ resource: A }, { resource: B }]), + undefined, + ); + }); + + test('a duplicated identity is reported as duplication, not as an extra session', () => { + // The signature downgrade defect: an older build re-adopts the same chat + // under a second row, which the returning build then reports twice. + assert.match( + describeIdentityMismatch([{ resource: A }, { resource: A }], [{ resource: A }])!, + /more than once: mock:\/session-a x2/, + ); + }); + + test('missing and unexpected identities are both named', () => { + assert.deepStrictEqual( + [ + describeIdentityMismatch([{ resource: A }], [{ resource: A }, { resource: B }]), + describeIdentityMismatch([{ resource: A }, { resource: B }], [{ resource: A }]), + ], + [ + 'listed sessions do not match: missing [mock:/session-b], unexpected []', + 'listed sessions do not match: missing [], unexpected [mock:/session-b]', + ], + ); + }); + + test('a title reverted by the returning build fails even though the session survived', () => { + assert.match( + describeIdentityMismatch( + [{ resource: A, title: 'Backward Compat Seed' }], + [{ resource: A, title: 'Renamed By Older Build' }], + )!, + /should carry title "Renamed By Older Build" but carries "Backward Compat Seed"/, + ); + }); + + test('titles are only asserted when the expectation states one', () => { + assert.strictEqual( + describeIdentityMismatch([{ resource: A, title: 'anything at all' }], [{ resource: A }]), + undefined, + ); + }); + + test('an empty profile matches an empty expectation', () => { + assert.strictEqual(describeIdentityMismatch([], []), undefined); + }); + + test('every older checkpoint is covered, oldest first, and the current build is not paired with itself', () => { + assert.deepStrictEqual([...BACKWARD_COMPAT_OLDER_BUILDS], ['legacy', 'intermediate', 'predecessor']); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.ts new file mode 100644 index 00000000000000..1e11c7662029d4 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.ts @@ -0,0 +1,627 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Backward-compatibility ("downgrade") and round-trip scenarios. + * + * The same-build restart baseline establishes that each checkpoint can reopen + * *its own* profile. This file asks the harder question that a real user asks + * by accident: what happens when a profile written by the **newest** build is + * then opened by an **older** one, and afterwards handed back? + * + * ```text + * phase 1: current phase 2: older build phase 3: current returns + * create A ─▶ rename A ─▶ list A ─▶ rename A ─▶ list {A, B} exactly once + * create B A keeps the OLD build's title + * subscribe A and B + * ─▶ restart ─▶ still stable + * ``` + * + * Three properties make the result meaningful: + * + * - **One profile throughout.** Every phase receives the identical home and + * user-data directory. A fresh profile anywhere would make the whole scenario + * vacuous, so the directories are created once, up front, and only passed + * around afterwards. + * - **Exactly-once identity.** The interesting downgrade failure is not a lost + * session but a *duplicated* one: an older build that cannot parse the newer + * catalogue may re-adopt the same underlying chat under a second identity, + * which then reappears as a phantom row when the newer build returns. The + * assertion is therefore on the exact multiset of resources, never on + * "contains". + * - **The older build's writes are authoritative.** Phase 3 requires the title + * the *older* build set, not the one the newer build seeded. A newer build + * that silently reverts to its own last-known value would still pass a naive + * "the session survived" check while destroying user edits. + * + * As with the baseline, everything is reached over AHP against real forked + * processes running the scripted mock provider: no host internals, no database + * reads, no log scraping, no model traffic. + */ + +import { mkdirSync, mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { timeout } from '../../../../../../base/common/async.js'; +import { join } from '../../../../../../base/common/path.js'; +import { LiveCompatAhpClient } from './agentHostLiveCompatClient.js'; +import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; +import { + createAgentHostCapabilityAdapter, + LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, + type IAgentHostCapabilityAdapter, +} from './agentHostLiveCompatCapabilities.js'; +import { startLiveCompatServer, stopLiveCompatServer, type ILiveCompatLaunchOptions, type ILiveCompatServerHandle } from './agentHostLiveCompatServer.js'; +import type { + AgentProviderCapabilities, + ILiveCompatInitializeResult, + ILiveCompatSessionList, + ILiveCompatSessionListItem, + ILiveCompatSubscribeResult, +} from './agentHostLiveCompatProtocol.js'; +import type { ILiveCompatStepResult } from './sameBuildRestartBaseline.js'; + +/** Root channel URI. A constant of the protocol, stable across every build. */ +const ROOT_CHANNEL = 'ahp-root://'; +/** Provider driven by every phase; see the baseline's header for why it is the mock. */ +const PROVIDER = 'mock'; +/** Title the newest build seeds in phase 1. */ +const CURRENT_SEED_TITLE = 'Backward Compat Seed'; +/** Title the *older* build overwrites it with in phase 2; phase 3 must see this one. */ +const OLDER_BUILD_TITLE = 'Renamed By Older Build'; +/** Title the older build gives the session it creates in phase 2. */ +const OLDER_BUILD_SECOND_TITLE = 'Created By Older Build'; + +const PER_CALL_TIMEOUT_MS = 30_000; + +/** See the baseline: a restored session is not describable the instant the socket opens. */ +const RESTORE_ATTEMPTS = 20; +const RESTORE_RETRY_DELAY_MS = 500; + +/** + * Budget for the returning build to converge on the older build's writes. + * + * Larger than the restore budget because convergence waits on a background + * reconciliation pass rather than on catalogue restore alone. + */ +const CONVERGENCE_ATTEMPTS = 60; + +/** + * Time allowed for catalogue writes to reach disk before a host is stopped. + * + * Identical in purpose to the baseline's window and load-bearing for the same + * reason: `listSessions` and `subscribe` are both served from memory, and the + * catalogue write that makes a create or rename durable is queued behind them + * with no AHP acknowledgment and no shutdown flush. Without this window a + * cross-build handover cannot distinguish "the older build could not read it" + * from "it was never written before the process stopped" — which is precisely + * the confusion this suite exists to eliminate. + */ +const HANDOVER_SETTLE_MS = 1_000; + +/** A session identity the matrix expects to observe, and the title it must carry. */ +export interface IExpectedSessionIdentity { + readonly resource: string; + /** Expected title, or `undefined` when titles are not asserted on this build. */ + readonly title?: string; +} + +/** Machine-readable result of one downgrade round trip. */ +export interface IBackwardCompatScenarioResult { + readonly scenario: string; + /** Build that seeds and later re-reads the profile. */ + readonly currentBuild: string; + /** Older build the profile is handed down to. */ + readonly olderBuild: string; + readonly olderBuildDescription?: string; + readonly outcome: 'passed' | 'failed'; + readonly durationMs: number; + /** Protocol version negotiated by the newest build. */ + readonly currentProtocolVersion?: string; + /** Protocol version negotiated by the older build. */ + readonly olderProtocolVersion?: string; + readonly steps: readonly ILiveCompatStepResult[]; + /** Retained profile + host logs for both builds. Never deleted. */ + readonly diagnosticsPath: string; + readonly error?: string; +} + +/** Aggregate outcome of a backward-compatibility run. */ +export interface IBackwardCompatMatrixSummary { + readonly suite: string; + readonly startedAt: string; + readonly durationMs: number; + readonly outcome: 'passed' | 'failed'; + readonly results: readonly IBackwardCompatScenarioResult[]; +} + +export interface IBackwardCompatScenarioOptions { + readonly diagnosticsRoot?: string; + readonly env?: Readonly>; +} + +/** + * Verify that `listed` is *exactly* `expected` — same identities, each once. + * + * Pure and exported so the exactly-once rule can be unit tested without + * launching four builds. Returns a human-readable explanation of the first + * discrepancy, or `undefined` when the listing matches. + * + * Duplicates are reported before missing/unexpected entries because a + * duplicated identity is the specific downgrade failure this suite is built to + * catch, and reporting it as "one unexpected extra" would understate it. + */ +export function describeIdentityMismatch( + listed: readonly ILiveCompatSessionListItem[], + expected: readonly IExpectedSessionIdentity[], +): string | undefined { + const counts = new Map(); + for (const item of listed) { + counts.set(item.resource, (counts.get(item.resource) ?? 0) + 1); + } + + const duplicated = [...counts].filter(([, count]) => count > 1).map(([resource, count]) => `${resource} x${count}`); + if (duplicated.length > 0) { + return `listed the same session identity more than once: ${duplicated.join(', ')}`; + } + + const expectedResources = expected.map(entry => entry.resource); + const missing = expectedResources.filter(resource => !counts.has(resource)); + const unexpected = [...counts.keys()].filter(resource => !expectedResources.includes(resource)); + if (missing.length > 0 || unexpected.length > 0) { + return `listed sessions do not match: missing [${missing.join(', ')}], unexpected [${unexpected.join(', ')}]`; + } + + for (const entry of expected) { + if (entry.title === undefined) { + continue; + } + const actual = listed.find(item => item.resource === entry.resource)?.title; + if (actual !== entry.title) { + return `session ${entry.resource} should carry title ${JSON.stringify(entry.title)} but carries ${JSON.stringify(actual)}`; + } + } + return undefined; +} + +/** Records step outcomes and their durations in performance order. */ +class StepRecorder { + private readonly _steps: ILiveCompatStepResult[] = []; + + get steps(): readonly ILiveCompatStepResult[] { + return this._steps; + } + + async run(name: string, body: () => Promise): Promise { + const startedAt = Date.now(); + try { + const result = await body(); + this._steps.push({ name, outcome: 'passed', durationMs: Date.now() - startedAt }); + return result; + } catch (error) { + this._steps.push({ name, outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }); + throw error; + } + } + + skip(name: string, reason: string): void { + this._steps.push({ name, outcome: 'skipped', durationMs: 0, detail: reason }); + } +} + +/** + * One phase: a launched build with a connected client and its capabilities. + * + * Phases are opened and closed explicitly rather than wrapped in a callback so + * that the scenario body reads in the order the steps actually happen, and so + * that a failure mid-phase still leaves the recorder holding every step that + * ran before it. + */ +interface IPhase { + readonly server: ILiveCompatServerHandle; + readonly client: LiveCompatAhpClient; + readonly adapter: IAgentHostCapabilityAdapter; + readonly protocolVersion: string; +} + +/** + * Run one downgrade round trip: current → older → current → restart. + * + * Never throws for a scenario failure; the failure is reported in the returned + * result so that a matrix run always covers every requested pairing. + */ +export async function runBackwardCompatibilityRoundTrip( + current: IPreparedAgentHostBuild, + older: IPreparedAgentHostBuild, + options: IBackwardCompatScenarioOptions = {}, +): Promise { + const startedAt = Date.now(); + const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-backward-compat-${older.id}-`)); + const dirs = createPersistentDirectories(diagnosticsPath); + const recorder = new StepRecorder(); + + let phase: IPhase | undefined; + let currentProtocolVersion: string | undefined; + let olderProtocolVersion: string | undefined; + + /** Sessions the *provider* must be told about on every subsequent launch. */ + const seededSessions: string[] = []; + const launchFor = (build: IPreparedAgentHostBuild): ILiveCompatLaunchOptions => ({ + serverEntry: build.serverEntry, + homeDir: dirs.homeDir, + userDataDir: dirs.userDataDir, + // The mock provider keeps its session index in memory, so each process + // is told which sessions the *provider* side already knows about — the + // same recovery a real provider performs from its own on-disk state. + // The host's catalogue, which is what is under test, is never seeded and + // must be reconstructed from the shared user-data directory alone. + env: seededSessions.length === 0 + ? options.env + : { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: seededSessions.join(',') }, + }); + + const openPhase = async (build: IPreparedAgentHostBuild, clientSuffix: string): Promise => { + const server = await startLiveCompatServer(launchFor(build)); + const client = new LiveCompatAhpClient(server.port); + await client.connect(); + const initialize = await client.call('initialize', { + channel: ROOT_CHANNEL, + protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], + clientId: `backward-compat-${build.id}-${clientSuffix}`, + }, PER_CALL_TIMEOUT_MS); + return { + server, + client, + protocolVersion: initialize.protocolVersion, + adapter: createAgentHostCapabilityAdapter({ + protocolVersion: initialize.protocolVersion, + providerCapabilities: await readProviderCapabilities(client), + }), + }; + }; + + /** + * Close a phase and wait for its process to exit. + * + * Awaiting the exit is what makes the handover a handover: the next build + * reuses the same user-data directory, and a predecessor still holding it + * would turn a compatibility result into a race between two processes. + */ + const closePhase = async (): Promise => { + phase?.client.close(); + const server = phase?.server; + phase = undefined; + await stopLiveCompatServer(server); + }; + + try { + // ── phase 1 ── the newest build seeds the profile ──────────────────── + phase = await recorder.run('current-seed:launch', () => openPhase(current, 'seed')); + currentProtocolVersion = phase.protocolVersion; + const currentAdapter = phase.adapter; + + await recorder.run('current-seed:list-empty', async () => { + const listed = await phase!.client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + assertOk(describeIdentityMismatch(listed.items ?? [], []), 'a fresh profile must list no sessions'); + }); + + const sessionA = `${PROVIDER}:/backward-compat-a-${Date.now()}`; + await recorder.run('current-seed:create-session-a', async () => { + await phase!.client.call('createSession', { channel: sessionA, provider: PROVIDER }, PER_CALL_TIMEOUT_MS); + await phase!.client.call('subscribe', { channel: sessionA }, PER_CALL_TIMEOUT_MS); + seededSessions.push(sessionA); + }); + + if (currentAdapter.supportsSessionRename) { + await recorder.run('current-seed:rename-session-a', async () => { + await dispatchTitle(phase!.client, sessionA, CURRENT_SEED_TITLE, 1); + }); + } else { + recorder.skip('current-seed:rename-session-a', `negotiated protocol ${currentAdapter.protocolVersion} predates client-dispatchable session/titleChanged`); + } + + await recorder.run('current-seed:handover', async () => { + await timeout(HANDOVER_SETTLE_MS); + await closePhase(); + }); + + // ── phase 2 ── the older build opens the newer build's profile ─────── + phase = await recorder.run('older:launch', () => openPhase(older, 'downgrade')); + olderProtocolVersion = phase.protocolVersion; + const olderAdapter = phase.adapter; + /** Titles are only asserted where *both* participating builds can set them. */ + const titlesComparable = currentAdapter.supportsSessionRename && olderAdapter.supportsSessionRename; + + await recorder.run('older:list-sees-seeded-session', async () => { + const listed = await listWithRestoreRetry(phase!.client, [sessionA]); + assertOk( + describeIdentityMismatch(listed, [{ resource: sessionA, title: titlesComparable ? CURRENT_SEED_TITLE : undefined }]), + 'the older build must list the newer build\'s session exactly once, with its title', + ); + }); + + await recorder.run('older:subscribe-seeded-session', async () => { + const state = await subscribeWithRestoreRetry(phase!.client, sessionA); + if (titlesComparable) { + assertEqual(state.title, CURRENT_SEED_TITLE, 'the older build must describe the seeded session with its title'); + } + }); + + if (olderAdapter.supportsSessionRename) { + await recorder.run('older:rename-seeded-session', async () => { + await dispatchTitle(phase!.client, sessionA, OLDER_BUILD_TITLE, 2); + }); + } else { + recorder.skip('older:rename-seeded-session', `negotiated protocol ${olderAdapter.protocolVersion} predates client-dispatchable session/titleChanged`); + } + + const sessionB = `${PROVIDER}:/backward-compat-b-${Date.now()}`; + await recorder.run('older:create-session-b', async () => { + await phase!.client.call('createSession', { channel: sessionB, provider: PROVIDER }, PER_CALL_TIMEOUT_MS); + await phase!.client.call('subscribe', { channel: sessionB }, PER_CALL_TIMEOUT_MS); + seededSessions.push(sessionB); + if (olderAdapter.supportsSessionRename) { + await dispatchTitle(phase!.client, sessionB, OLDER_BUILD_SECOND_TITLE, 3); + } + }); + + await recorder.run('older:handover', async () => { + await timeout(HANDOVER_SETTLE_MS); + await closePhase(); + }); + + // ── phase 3 ── the newest build takes the profile back ─────────────── + const expected: readonly IExpectedSessionIdentity[] = [ + { resource: sessionA, title: titlesComparable ? OLDER_BUILD_TITLE : undefined }, + { resource: sessionB, title: titlesComparable ? OLDER_BUILD_SECOND_TITLE : undefined }, + ]; + + phase = await recorder.run('current-return:launch', () => openPhase(current, 'return')); + + await recorder.run('current-return:list-exactly-expected', async () => { + const listed = await listUntilExpected(phase!.client, expected); + assertOk( + describeIdentityMismatch(listed, expected), + 'the returning build must list both sessions exactly once and preserve the older build\'s titles', + ); + }); + + await recorder.run('current-return:subscribe-both', async () => { + await subscribeBoth(phase!.client, expected, titlesComparable); + }); + + // ── phase 4 ── the newest build restarts on the round-tripped profile ─ + await recorder.run('current-return:restart', async () => { + await timeout(HANDOVER_SETTLE_MS); + await closePhase(); + phase = await openPhase(current, 'restart'); + }); + + await recorder.run('current-restart:list-exactly-expected', async () => { + const listed = await listUntilExpected(phase!.client, expected); + assertOk(describeIdentityMismatch(listed, expected), 'a round-tripped profile must remain stable across a further restart'); + }); + + await recorder.run('current-restart:subscribe-both', async () => { + await subscribeBoth(phase!.client, expected, titlesComparable); + }); + + // Permanent deletion is deliberately not exercised: AHP's `disposeSession` + // releases a channel, it does not delete durable session state, and no + // command in the shared protocol surface removes a session from the + // catalogue. Asserting on delete/recreate would therefore require + // reaching past the protocol into the host's storage, which this suite + // does not do. Recorded as a skip so the coverage gap is visible in the + // result rather than implied by its absence. + recorder.skip('delete-recreate', 'AHP exposes no permanent session delete (disposeSession only releases a channel); needs a protocol affordance before it can be covered externally'); + + return result(current, older, recorder, diagnosticsPath, startedAt, currentProtocolVersion, olderProtocolVersion, undefined); + } catch (error) { + return result(current, older, recorder, diagnosticsPath, startedAt, currentProtocolVersion, olderProtocolVersion, messageOf(error)); + } finally { + phase?.client.close(); + await stopLiveCompatServer(phase?.server).catch(() => undefined); + } +} + +/** + * Run the round trip for each older checkpoint, in order. + * + * Sequential by construction: every scenario forks real Agent Host processes + * that share this machine's temp space, and serializing keeps a failure + * attributable to one pairing rather than to contention between them. + */ +export async function runBackwardCompatibilityMatrix( + current: IPreparedAgentHostBuild, + olderBuilds: readonly IPreparedAgentHostBuild[], + options: IBackwardCompatScenarioOptions = {}, +): Promise { + const startedAt = Date.now(); + const results: IBackwardCompatScenarioResult[] = []; + for (const older of olderBuilds) { + results.push(await runBackwardCompatibilityRoundTrip(current, older, options)); + } + return { + suite: 'agent-host-live-compat/backward-compatibility-round-trip', + startedAt: new Date(startedAt).toISOString(), + durationMs: Date.now() - startedAt, + outcome: results.every(entry => entry.outcome === 'passed') ? 'passed' : 'failed', + results, + }; +} + +/** Build a failed result for a checkpoint that could not even be resolved. */ +export function unresolvedBackwardCompatResult(currentBuildId: string, olderBuildId: string, reason: string): IBackwardCompatScenarioResult { + return { + scenario: 'backward-compatibility-round-trip', + currentBuild: currentBuildId, + olderBuild: olderBuildId, + outcome: 'failed', + durationMs: 0, + steps: [{ name: 'resolve-build', outcome: 'failed', durationMs: 0, detail: reason }], + diagnosticsPath: '', + error: reason, + }; +} + +async function subscribeBoth( + client: LiveCompatAhpClient, + expected: readonly IExpectedSessionIdentity[], + assertTitles: boolean, +): Promise { + for (const entry of expected) { + const state = await subscribeWithRestoreRetry(client, entry.resource); + if (assertTitles && entry.title !== undefined) { + assertEqual(state.title, entry.title, `the resubscribed session ${entry.resource} must retain its title`); + } + } +} + +/** + * Dispatch a rename and wait until it is observable. + * + * `dispatchAction` is a write-ahead notification with no response, so the + * readback is the only confirmation that the host accepted and reduced it. + */ +async function dispatchTitle(client: LiveCompatAhpClient, sessionUri: string, title: string, clientSeq: number): Promise { + client.notify('dispatchAction', { + channel: sessionUri, + clientSeq, + action: { type: 'session/titleChanged', title }, + }); + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); + if (subscribed.snapshot?.state?.title === title) { + return; + } + await timeout(RESTORE_RETRY_DELAY_MS); + } + throw new Error(`the dispatched title ${JSON.stringify(title)} never became observable on ${sessionUri}`); +} + +/** + * List sessions, retrying until every awaited identity has appeared. + * + * A build restoring a profile populates its catalogue concurrently with + * accepting connections, so an immediate `listSessions` can legitimately answer + * with a partial set. Retrying is part of the contract a client must implement; + * the budget is bounded so a genuinely lost session still fails, and the last + * observed listing is returned so the caller's assertion reports what was + * actually there rather than a timeout. + */ +async function listWithRestoreRetry(client: LiveCompatAhpClient, awaited: readonly string[]): Promise { + let items: readonly ILiveCompatSessionListItem[] = []; + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + const listed = await client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + items = listed.items ?? []; + if (awaited.every(resource => items.some(item => item.resource === resource))) { + return items; + } + await timeout(RESTORE_RETRY_DELAY_MS); + } + return items; +} + +/** + * List until the whole expectation holds, not merely until the identities exist. + * + * A returning build serves `listSessions` from its central catalogue, which an + * older build cannot write; the newer build repairs those rows in a background + * reconciliation pass that is *scheduled*, not awaited by the protocol. The + * contract a client sees is therefore eventual, so the assertion polls the + * observable AHP surface until it converges instead of sleeping for a fixed + * period. The budget is bounded, and the last listing is returned so a genuine + * failure is reported as the mismatch it is rather than as a timeout. + */ +async function listUntilExpected( + client: LiveCompatAhpClient, + expected: readonly IExpectedSessionIdentity[], +): Promise { + let items: readonly ILiveCompatSessionListItem[] = []; + for (let attempt = 0; attempt < CONVERGENCE_ATTEMPTS; attempt++) { + const listed = await client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + items = listed.items ?? []; + if (describeIdentityMismatch(items, expected) === undefined) { + return items; + } + await timeout(RESTORE_RETRY_DELAY_MS); + } + return items; +} + +/** Subscribe to a restored session, tolerating the transient describe window. */ +async function subscribeWithRestoreRetry(client: LiveCompatAhpClient, sessionUri: string): Promise<{ title?: string }> { + let lastError: unknown; + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + try { + const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); + return subscribed.snapshot?.state ?? {}; + } catch (error) { + lastError = error; + await timeout(RESTORE_RETRY_DELAY_MS); + } + } + throw new Error(`could not resubscribe to ${sessionUri} within ${RESTORE_ATTEMPTS} attempts: ${messageOf(lastError)}`); +} + +/** Read provider capabilities off the root snapshot, as any client would. */ +async function readProviderCapabilities(client: LiveCompatAhpClient): Promise> { + const root = await client.call('subscribe', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + const capabilities = new Map(); + for (const agent of root.snapshot?.state?.agents ?? []) { + capabilities.set(agent.provider, agent.capabilities ?? {}); + } + return capabilities; +} + +function createPersistentDirectories(root: string): { homeDir: string; userDataDir: string } { + const homeDir = join(root, 'home'); + const userDataDir = join(root, 'user-data'); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(join(homeDir, '.codex'), { recursive: true }); + mkdirSync(userDataDir, { recursive: true }); + mkdirSync(join(root, 'workspace'), { recursive: true }); + return { homeDir, userDataDir }; +} + +function result( + current: IPreparedAgentHostBuild, + older: IPreparedAgentHostBuild, + recorder: StepRecorder, + diagnosticsPath: string, + startedAt: number, + currentProtocolVersion: string | undefined, + olderProtocolVersion: string | undefined, + error: string | undefined, +): IBackwardCompatScenarioResult { + return { + scenario: 'backward-compatibility-round-trip', + currentBuild: current.id, + olderBuild: older.id, + olderBuildDescription: older.description, + outcome: error === undefined ? 'passed' : 'failed', + durationMs: Date.now() - startedAt, + currentProtocolVersion, + olderProtocolVersion, + steps: recorder.steps, + diagnosticsPath, + ...(error === undefined ? {} : { error }), + }; +} + +function assertOk(mismatch: string | undefined, what: string): void { + if (mismatch !== undefined) { + throw new Error(`${what}: ${mismatch}`); + } +} + +function assertEqual(actual: T, expected: T, what: string): void { + if (actual !== expected) { + throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.test.ts new file mode 100644 index 00000000000000..22eed6e82f0061 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.test.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Unit coverage for the forward-migration matrix's *composition* rules. + * + * What this file deliberately does not do is launch a build. The scenario body + * is exercised for real by the live run (`--run-forward-migrations`), and + * duplicating that here would trade a twelve-minute honest signal for a fast + * dishonest one. What is worth pinning cheaply is the surrounding contract: the + * pair list, and the promise that an unresolvable checkpoint is reported as a + * failed row carrying the resolver's explanation rather than skipped. + */ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; +import { FORWARD_MIGRATION_SOURCES, runForwardMigrations } from './runForwardMigrationMatrix.js'; + +suite('Agent Host forward-migration matrix', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('every historical checkpoint upgrades to the current working tree', () => { + assert.deepStrictEqual( + [...FORWARD_MIGRATION_SOURCES], + [AgentHostBuildId.Legacy, AgentHostBuildId.Predecessor, AgentHostBuildId.Intermediate], + ); + }); + + test('an unpreparable checkpoint is a reported failure, never a silent skip', async () => { + const summary = await runForwardMigrations({ + // A repository root with no prepared cache: resolution must fail for + // every pair, which is precisely the condition under test. + repoRoot: '/nonexistent-agent-host-live-compat-root', + cacheRoot: '/nonexistent-agent-host-live-compat-cache', + resolveCommit: () => undefined, + includeMultiSession: false, + }); + + assert.deepStrictEqual( + { + outcome: summary.outcome, + rows: summary.results.map(result => ({ + build: result.build, + outcome: result.outcome, + steps: result.steps.map(step => step.name), + hasExplanation: (result.error ?? '').length > 0, + })), + }, + { + outcome: 'failed', + rows: [ + { build: 'legacy->current', outcome: 'failed', steps: ['resolve-build'], hasExplanation: true }, + { build: 'predecessor->current', outcome: 'failed', steps: ['resolve-build'], hasExplanation: true }, + { build: 'intermediate->current', outcome: 'failed', steps: ['resolve-build'], hasExplanation: true }, + ], + }, + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.ts new file mode 100644 index 00000000000000..2078c829b46f26 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.ts @@ -0,0 +1,624 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The forward-migration matrix: an older build seeds a profile, the current + * build inherits it. + * + * This is the scenario the whole live-compat apparatus exists for. The + * same-build restart baseline established that each checkpoint round-trips its + * *own* profile; that result is what makes a failure here attributable. If a + * build can reopen what it wrote, but the current build cannot reopen what that + * build wrote, the difference is a forward-migration defect and nothing else. + * + * Shape of a run, all of it over AHP against real forked server processes: + * + * ```text + * phase 1 — source build phase 2 — current build phase 3 — current build + * (legacy | predecessor | (same home + user-data) (same home + user-data) + * intermediate) + * initialize initialize initialize + * list (empty) list ─┐ list ─┐ + * create session(s) + cwd subscribe ├ restored subscribe ├ identical + * rename session(s) assert ─┘ assert ─┘ + * stop cleanly (idempotent) + * ``` + * + * Four properties are load-bearing, and each is a rule rather than an + * implementation detail: + * + * - **One profile, three launches.** The home, user-data and workspace + * directories are created once and handed unchanged to every phase. The + * inheritance *is* the subject; a fresh profile in phase 2 would make every + * assertion vacuous. + * - **Clean handover.** The source build is stopped and awaited before the + * current build is launched, so phase 2 reads a profile that was closed + * rather than one still being written. + * - **External only.** Nothing here reads the host database, imports host + * internals, or inspects logs for assertions. Every claim is a readback over + * AHP, which is the only surface a real client has. + * - **Contract differences come from the wire.** The source builds negotiate + * older protocol versions (0.8 and 1.0 are both in the matrix today) and may + * not carry every field. Those differences are resolved through the + * capability adapter and through what the *source build itself was observed + * to report* — never from the checkpoint id. A field the source never + * reported is not asserted after migration, because its absence would be a + * property of the seed, not of the migration. Where a field turns out to be + * unstable for reasons unrelated to migration, it is recorded as an explicit + * skip with evidence (see {@link WORKING_DIRECTORY_SKIP_REASON}) rather than + * asserted or quietly dropped. + * + * The scenario runs against the scripted mock provider and never contacts a + * model: the subject is the host's own persistence and migration, so a + * provider that needs replay fixtures recorded per checkpoint would only add a + * second, unrelated way to fail. + */ + +import { mkdirSync, mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { timeout } from '../../../../../../base/common/async.js'; +import { join } from '../../../../../../base/common/path.js'; +import { LiveCompatAhpClient } from './agentHostLiveCompatClient.js'; +import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; +import { + createAgentHostCapabilityAdapter, + LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, + type IAgentHostCapabilityAdapter, +} from './agentHostLiveCompatCapabilities.js'; +import { startLiveCompatServer, stopLiveCompatServer, type ILiveCompatLaunchOptions, type ILiveCompatServerHandle } from './agentHostLiveCompatServer.js'; +import type { + AgentProviderCapabilities, + ILiveCompatInitializeResult, + ILiveCompatSessionList, + ILiveCompatSessionListItem, + ILiveCompatSubscribeResult, +} from './agentHostLiveCompatProtocol.js'; +import type { ILiveCompatScenarioResult, ILiveCompatStepResult } from './sameBuildRestartBaseline.js'; + +/** Root channel URI. A constant of the protocol, stable across every build. */ +const ROOT_CHANNEL = 'ahp-root://'; +/** Provider the matrix drives; see the file header for why it is the mock. */ +const PROVIDER = 'mock'; +const PER_CALL_TIMEOUT_MS = 30_000; + +/** + * A restored session is not necessarily describable the instant the host is + * accepting connections: the provider is re-registered and the catalogue + * re-read concurrently with the socket opening. Retrying is part of the + * contract a client implements, not a workaround — but the budget is bounded + * so a genuinely lost session still fails. + */ +const RESTORE_ATTEMPTS = 20; +const RESTORE_RETRY_DELAY_MS = 500; + +/** + * Time allowed for the seed's catalogue writes to reach disk before the source + * build is stopped. + * + * The host exposes no durability acknowledgment for the catalogue write, and + * does not await it during shutdown, so a bounded settle window is currently + * the only way to distinguish "the migration lost it" from "it was never + * written". Making this unnecessary is a host-side change (an observable + * durability ack), not a scenario change. + */ +const SEED_SETTLE_MS = 1_000; + +/** + * Extra narrowing of the wire shapes, on top of the shared protocol module. + * + * `workingDirectories` is asserted only by this matrix — the restart baseline + * has no reason to look at it — so it is declared here rather than widened in + * the shared module. Widening that module is a statement that *every* scenario + * depends on the field; this is the narrower, truer statement. + */ +interface IForwardMigrationSessionItem extends ILiveCompatSessionListItem { + readonly workingDirectories?: readonly string[]; +} + +interface IForwardMigrationSessionList extends ILiveCompatSessionList { + readonly items?: readonly IForwardMigrationSessionItem[]; +} + +interface IForwardMigrationChannelState { + readonly title?: string; + readonly workingDirectories?: readonly string[]; +} + +interface IForwardMigrationSubscribeResult extends ILiveCompatSubscribeResult { + readonly snapshot?: { readonly state?: IForwardMigrationChannelState }; +} + +/** + * Why working directories are seeded but not asserted after the handover. + * + * A session is created here *with* a working directory, and the source build + * reports it back — so the seed is real. But the scripted mock provider only + * ever reports `workingDirectories` from its creation path; its re-description + * paths (`listSessions`, `getSessionMetadata`) omit the field entirely. Once a + * restarted host re-describes a session from the provider, the field is + * therefore absent at the source, and the host's catalogue follows. + * + * This was measured rather than assumed. Running this same scenario with the + * working tree as *both* source and target — an upgrade that migrates nothing — + * reproduces it exactly: the first reopen still carries the directories, and + * the second, after the provider has re-described the session, does not. A + * defect that reproduces with migration removed is not a migration defect. + * + * So asserting on it here would report a property of the reference provider as + * a forward-compatibility failure on every pair in the matrix, which is worse + * than not covering it: it would make the matrix loud and wrong. The step is + * recorded as an explicit skip carrying this reason instead, keeping the + * coverage honest rather than silently narrower than it looks. Closing it needs + * a provider that re-describes working directories (a change to shared + * `mockAgent.ts`, out of scope here) or a bundled provider, not a change to + * this scenario. + */ +const WORKING_DIRECTORY_SKIP_REASON = + 'the mock provider reports workingDirectories only on creation, never on re-description; ' + + 'reproduced with current->current, so it is a provider limitation rather than a migration defect'; + +/** + * What phase 1 durably established about one session, as *observed over AHP + * from the source build itself*. + * + * Recording the observation rather than the intent is what keeps the matrix + * honest across contract evolution. If a source build never reported a title, + * phase 2 does not assert one: the absence would say something about the seed, + * not about the migration under test. + */ +interface ISeededSession { + readonly resource: string; + /** Title the source build reported back, if it reported one at all. */ + readonly title: string | undefined; + /** + * Working directories the source build reported back, if any. Recorded for + * the diagnostics record only; see {@link WORKING_DIRECTORY_SKIP_REASON}. + */ + readonly workingDirectories: readonly string[] | undefined; +} + +export interface IForwardMigrationOptions { + /** Root under which the per-scenario diagnostics directory is created. */ + readonly diagnosticsRoot?: string; + /** Extra environment for every launch. */ + readonly env?: Readonly>; + /** + * How many sessions to seed. One is the canonical case; a multi-session run + * additionally exercises that migration preserves a *set* rather than + * merely a single row, and that identities are not conflated. + */ + readonly sessionCount?: number; + /** Distinguishes result rows when a pair is run at several session counts. */ + readonly scenarioSuffix?: string; +} + +/** Aggregate outcome of one forward-migration run. */ +export interface IForwardMigrationSummary { + readonly suite: string; + readonly startedAt: string; + readonly durationMs: number; + readonly outcome: 'passed' | 'failed'; + readonly results: readonly ILiveCompatScenarioResult[]; +} + +/** Records step outcomes and their durations in performance order. */ +class StepRecorder { + private readonly _steps: ILiveCompatStepResult[] = []; + + get steps(): readonly ILiveCompatStepResult[] { + return this._steps; + } + + async run(name: string, body: () => Promise): Promise { + const startedAt = Date.now(); + try { + const result = await body(); + this._steps.push({ name, outcome: 'passed', durationMs: Date.now() - startedAt }); + return result; + } catch (error) { + this._steps.push({ name, outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }); + throw error; + } + } + + skip(name: string, reason: string): void { + this._steps.push({ name, outcome: 'skipped', durationMs: 0, detail: reason }); + } +} + +/** + * Run one forward-migration scenario: `source` seeds a profile, `target` + * inherits it, and `target` is then restarted to show the result is stable. + * + * Never throws for a scenario failure. A failed pair is data the caller needs + * alongside the pairs that passed, so the failure is reported in the returned + * result; only a defect in the runner itself propagates. + */ +export async function runForwardMigrationScenario( + source: IPreparedAgentHostBuild, + target: IPreparedAgentHostBuild, + options: IForwardMigrationOptions = {}, +): Promise { + const sessionCount = options.sessionCount ?? 1; + const scenario = `forward-migration${options.scenarioSuffix ? `/${options.scenarioSuffix}` : ''}`; + const startedAt = Date.now(); + const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-forward-${source.id}-to-${target.id}-`)); + const dirs = createSharedProfile(diagnosticsPath); + const recorder = new StepRecorder(); + let server: ILiveCompatServerHandle | undefined; + let client: LiveCompatAhpClient | undefined; + /** The negotiated version of the *target*: the build the claim is about. */ + let protocolVersion: string | undefined; + + const launchOn = (build: IPreparedAgentHostBuild, env?: Readonly>): ILiveCompatLaunchOptions => ({ + serverEntry: build.serverEntry, + homeDir: dirs.homeDir, + userDataDir: dirs.userDataDir, + env: { ...options.env, ...env }, + }); + + try { + // ── phase 1: the source build seeds the profile ────────────────────── + server = await recorder.run('launch-source', () => startLiveCompatServer(launchOn(source))); + client = await connect(server); + + const sourceAdapter = await recorder.run('initialize-source', async () => { + const initialize = await client!.call('initialize', { + channel: ROOT_CHANNEL, + protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], + clientId: `forward-${source.id}-seed`, + }, PER_CALL_TIMEOUT_MS); + return createAgentHostCapabilityAdapter({ + protocolVersion: initialize.protocolVersion, + providerCapabilities: await readProviderCapabilities(client!), + }); + }); + + await recorder.run('list-empty-source', async () => { + const listed = await client!.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + assertEqual(listed.items?.length ?? 0, 0, 'a fresh profile must list no sessions'); + }); + + const seeded = await seedSessions(recorder, client, sourceAdapter, source, dirs.workspaceDir, sessionCount); + + await recorder.run('stop-source', async () => { + client!.close(); + client = undefined; + await stopLiveCompatServer(server); + server = undefined; + }); + + // ── phase 2: the target build inherits the profile ─────────────────── + // The mock provider keeps its session index in memory, so the new + // process is told which sessions the *provider* side already knows + // about — mirroring what a real provider recovers from its own on-disk + // state. The host's persistence, which is what is under test, is not + // seeded and must be reconstructed from the retained user-data + // directory alone. + const mockSeed = { VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: seeded.map(session => session.resource).join(',') }; + + protocolVersion = await recorder.run('launch-target', async () => { + server = await startLiveCompatServer(launchOn(target, mockSeed)); + client = await connect(server); + const initialize = await client.call('initialize', { + channel: ROOT_CHANNEL, + protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], + clientId: `forward-${source.id}-to-${target.id}-verify`, + }, PER_CALL_TIMEOUT_MS); + return initialize.protocolVersion; + }); + + const migrated = await recorder.run('list-migrated', () => assertListMatchesSeed(client!, seeded)); + await recorder.run('subscribe-migrated', () => assertSubscribeMatchesSeed(client!, seeded)); + recorder.skip('working-directories-preserved', WORKING_DIRECTORY_SKIP_REASON); + + // ── phase 3: the same target build, restarted ──────────────────────── + // Migration must be a fixed point. A run that converts on first open + // but keeps converting — or worse, converges to something different — + // would pass phase 2 and still be broken in the only way users meet it: + // the second launch. + await recorder.run('restart-target', async () => { + client!.close(); + client = undefined; + await stopLiveCompatServer(server); + server = undefined; + server = await startLiveCompatServer(launchOn(target, mockSeed)); + client = await connect(server); + await client.call('initialize', { + channel: ROOT_CHANNEL, + protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], + clientId: `forward-${source.id}-to-${target.id}-idempotent`, + }, PER_CALL_TIMEOUT_MS); + }); + + await recorder.run('list-idempotent', async () => { + const again = await assertListMatchesSeed(client!, seeded); + // Compared against phase 2's readback rather than against the seed + // alone: that is what makes this an idempotence claim instead of a + // second, weaker restore claim. + assertEqual( + JSON.stringify(again), + JSON.stringify(migrated), + 'a second launch of the migrated profile must produce an identical listing', + ); + }); + await recorder.run('subscribe-idempotent', () => assertSubscribeMatchesSeed(client!, seeded)); + + return result(scenario, source, target, recorder, diagnosticsPath, startedAt, protocolVersion, undefined); + } catch (error) { + return result(scenario, source, target, recorder, diagnosticsPath, startedAt, protocolVersion, messageOf(error)); + } finally { + client?.close(); + await stopLiveCompatServer(server).catch(() => undefined); + } +} + +/** + * Run the forward-migration matrix: every requested source build, upgraded to + * the same target, in order. + * + * Builds run sequentially. Each scenario forks two real Agent Hosts from + * different compiled trees sharing this machine's temp space; serializing keeps + * a failure attributable to one pair rather than to contention between them. + */ +export async function runForwardMigrationMatrix( + pairs: readonly { readonly source: IPreparedAgentHostBuild; readonly target: IPreparedAgentHostBuild; readonly options?: IForwardMigrationOptions }[], +): Promise { + const startedAt = Date.now(); + const results: ILiveCompatScenarioResult[] = []; + for (const pair of pairs) { + results.push(await runForwardMigrationScenario(pair.source, pair.target, pair.options)); + } + return { + suite: 'agent-host-live-compat/forward-migration', + startedAt: new Date(startedAt).toISOString(), + durationMs: Date.now() - startedAt, + outcome: results.every(entry => entry.outcome === 'passed') ? 'passed' : 'failed', + results, + }; +} + +/** + * Create sessions on the source build and record what that build reports back. + * + * The readback is the point. Everything phase 2 asserts is drawn from what the + * source build itself was seen to hold, so the matrix tests migration rather + * than the union of migration and whatever the seeding build happened to + * support. + */ +async function seedSessions( + recorder: StepRecorder, + client: LiveCompatAhpClient | undefined, + adapter: IAgentHostCapabilityAdapter, + source: IPreparedAgentHostBuild, + workspaceDir: string, + sessionCount: number, +): Promise { + const created = await recorder.run('create-sessions', async () => { + const uris: string[] = []; + for (let index = 0; index < sessionCount; index++) { + const uri = `${PROVIDER}:/forward-${source.id}-${Date.now()}-${index}`; + await client!.call('createSession', { + channel: uri, + provider: PROVIDER, + workingDirectories: [uriForDirectory(workspaceDir)], + }, PER_CALL_TIMEOUT_MS); + await client!.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); + uris.push(uri); + } + return uris; + }); + + if (!adapter.supportsSessionRename) { + recorder.skip('rename-sessions', `negotiated protocol ${adapter.protocolVersion} predates client-dispatchable session/titleChanged`); + } else { + await recorder.run('rename-sessions', async () => { + for (const [index, uri] of created.entries()) { + // `dispatchAction` is a write-ahead notification, so the + // readback below is what confirms the host accepted it. + client!.notify('dispatchAction', { + channel: uri, + clientSeq: index + 1, + action: { type: 'session/titleChanged', title: titleFor(source, index) }, + }); + } + for (const [index, uri] of created.entries()) { + const state = await pollForTitle(client!, uri, titleFor(source, index)); + assertEqual(state.title, titleFor(source, index), `the dispatched title for ${uri} must be observable before the handover`); + } + }); + } + + // Give the catalogue writes a chance to land before the process is stopped; + // see the note on SEED_SETTLE_MS for why an explicit window is the honest + // instrument here rather than a retry that would hide the distinction. + await recorder.run('settle-seed', () => timeout(SEED_SETTLE_MS)); + + return recorder.run('read-seed', async () => { + const listed = await client!.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + assertEqual(listed.items?.length ?? 0, created.length, 'the source build must list exactly the sessions it just created'); + return created.map(resource => { + const item = listed.items?.find(candidate => candidate.resource === resource); + assertEqual(item?.resource, resource, `the source build must list the session it created at ${resource}`); + return { + resource, + title: item?.title, + workingDirectories: item?.workingDirectories === undefined ? undefined : [...item.workingDirectories].sort(), + } satisfies ISeededSession; + }); + }); +} + +/** + * The durable facts a listing is compared on across launches. + * + * Working directories are excluded deliberately, and this is the one place + * where that exclusion is load-bearing rather than merely unasserted: per + * {@link WORKING_DIRECTORY_SKIP_REASON} the field is present on the first + * reopen and absent on the second, so including it would make the idempotence + * comparison fail on a provider artifact and hide any real instability in the + * fields that do carry a migration claim. + */ +interface IObservedSession { + readonly resource: string; + readonly title: string | undefined; +} + +/** + * Assert the migrated profile lists exactly the seeded sessions, and return + * the normalized listing so a later launch can be compared against it. + */ +async function assertListMatchesSeed(client: LiveCompatAhpClient, seeded: readonly ISeededSession[]): Promise { + let last: readonly IForwardMigrationSessionItem[] = []; + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + const listed = await client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + last = listed.items ?? []; + if (last.length === seeded.length) { + break; + } + // The catalogue is re-read concurrently with the socket opening, so a + // short listing is transient early and only meaningful once the budget + // is spent. + await timeout(RESTORE_RETRY_DELAY_MS); + } + + assertEqual(last.length, seeded.length, 'the migrated profile must list exactly the seeded sessions, and no others'); + const observed: IObservedSession[] = []; + for (const session of seeded) { + const item = last.find(candidate => candidate.resource === session.resource); + assertEqual(item?.resource, session.resource, `the session ${session.resource} must survive the upgrade with its identity intact`); + assertEqual(item?.provider ?? PROVIDER, PROVIDER, `the session ${session.resource} must keep its provider`); + if (session.title !== undefined) { + assertEqual(item?.title, session.title, `the session ${session.resource} must keep the title the source build held`); + } + observed.push({ resource: session.resource, title: item?.title }); + } + return observed; +} + +/** Assert each seeded session is individually describable after migration. */ +async function assertSubscribeMatchesSeed(client: LiveCompatAhpClient, seeded: readonly ISeededSession[]): Promise { + for (const session of seeded) { + const state = await subscribeWithRestoreRetry(client, session.resource); + if (session.title !== undefined) { + assertEqual(state.title, session.title, `the resubscribed session ${session.resource} must keep its title`); + } + } +} + +/** + * Create the profile every phase shares. + * + * Created once, deliberately: the inheritance across launches is the subject of + * the scenario, so these paths are computed here and never re-derived per + * phase, where a divergence would silently turn the run into three unrelated + * fresh-profile runs that all pass. + */ +function createSharedProfile(root: string): { homeDir: string; userDataDir: string; workspaceDir: string } { + const homeDir = join(root, 'home'); + const userDataDir = join(root, 'user-data'); + const workspaceDir = join(root, 'workspace'); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(join(homeDir, '.codex'), { recursive: true }); + mkdirSync(userDataDir, { recursive: true }); + mkdirSync(workspaceDir, { recursive: true }); + return { homeDir, userDataDir, workspaceDir }; +} + +/** + * A `file:` URI for an absolute directory path. + * + * Hand-built rather than taken from `URI.file`: this module is loaded by a + * plain `node` runner as well as by Mocha, and the paths involved are temp + * directories the scenario created itself, so the general-purpose encoder's + * behavior is not needed. Encoding is still applied so a temp root containing + * spaces cannot produce a malformed URI. + */ +function uriForDirectory(path: string): string { + const normalized = path.replace(/\\/g, '/'); + const withLeadingSlash = normalized.startsWith('/') ? normalized : `/${normalized}`; + return `file://${withLeadingSlash.split('/').map(encodeURIComponent).join('/')}`; +} + +function titleFor(source: IPreparedAgentHostBuild, index: number): string { + return `Forward Migration ${source.id} #${index + 1}`; +} + +async function connect(server: ILiveCompatServerHandle): Promise { + const client = new LiveCompatAhpClient(server.port); + await client.connect(); + return client; +} + +/** Read provider capabilities off the root snapshot, as any client would. */ +async function readProviderCapabilities(client: LiveCompatAhpClient): Promise> { + const root = await client.call('subscribe', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + const capabilities = new Map(); + for (const agent of root.snapshot?.state?.agents ?? []) { + capabilities.set(agent.provider, agent.capabilities ?? {}); + } + return capabilities; +} + +async function pollForTitle(client: LiveCompatAhpClient, sessionUri: string, expected: string): Promise { + let state: IForwardMigrationChannelState = {}; + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); + state = subscribed.snapshot?.state ?? {}; + if (state.title === expected) { + return state; + } + await timeout(RESTORE_RETRY_DELAY_MS); + } + return state; +} + +/** Subscribe to a migrated session, tolerating the transient describe window. */ +async function subscribeWithRestoreRetry(client: LiveCompatAhpClient, sessionUri: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + try { + const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); + return subscribed.snapshot?.state ?? {}; + } catch (error) { + lastError = error; + await timeout(RESTORE_RETRY_DELAY_MS); + } + } + throw new Error(`could not resubscribe to ${sessionUri} within ${RESTORE_ATTEMPTS} attempts: ${messageOf(lastError)}`); +} + +function result( + scenario: string, + source: IPreparedAgentHostBuild, + target: IPreparedAgentHostBuild, + recorder: StepRecorder, + diagnosticsPath: string, + startedAt: number, + protocolVersion: string | undefined, + error: string | undefined, +): ILiveCompatScenarioResult { + return { + scenario, + build: `${source.id}->${target.id}`, + buildDescription: `${source.description ?? source.id} → ${target.description ?? target.id}`, + outcome: error === undefined ? 'passed' : 'failed', + durationMs: Date.now() - startedAt, + protocolVersion, + steps: recorder.steps, + diagnosticsPath, + ...(error === undefined ? {} : { error }), + }; +} + +function assertEqual(actual: T, expected: T, what: string): void { + if (actual !== expected) { + throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/liveCompatRunner.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/liveCompatRunner.test.ts new file mode 100644 index 00000000000000..5d8aa379591b60 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/liveCompatRunner.test.ts @@ -0,0 +1,186 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Contract tests for the live-compat CLI, driven as a black box. + * + * The CLI is the only thing a developer or a CI job ever touches, so the + * properties worth pinning here are the ones a caller depends on and cannot see + * from the matrices themselves: + * + * - `--check` is **non-destructive**: it reports readiness and never prepares, + * compiles, checks out or writes anything. + * - An unprepared checkpoint is a **reported failure with a nonzero exit**, + * never a skip — so a run covering two of three upgrades can never be + * mistaken for one covering three. + * - Summaries land at **stable paths**, which is what makes CI able to collect + * evidence by name rather than by glob-and-hope. + * + * These run the script against a deliberately empty cache root, so no build is + * ever launched and the suite stays fast. The scenario bodies are exercised for + * real by `npm run agent-host-live-compat-all`. + */ + +import assert from 'assert'; +import { spawnSync } from 'child_process'; +import { existsSync, mkdtempSync, readdirSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { fileURLToPath } from 'url'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; + +const repoRoot = fileURLToPath(new URL('../../../../../../../../', import.meta.url)); +const cliPath = join(repoRoot, 'scripts', 'test-agent-host-live-compat.ts'); + +interface ICliResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +/** + * Run the CLI against an empty cache root so no historical build resolves. + * + * `ELECTRON_RUN_AS_NODE` matters: this suite executes inside Electron, whose + * `execPath` would otherwise boot a renderer instead of running the script. + */ +function runCli(args: readonly string[], cacheRoot: string): ICliResult { + const result = spawnSync(process.execPath, [cliPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', AGENT_HOST_LIVE_COMPAT_CACHE: cacheRoot }, + }); + if (result.error) { + throw result.error; + } + return { status: result.status ?? -1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +suite('Agent Host live-compat runner', function () { + + // Every test forks the real CLI, which costs a process start each time. + this.timeout(60_000); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('--check reports unprepared builds without preparing anything', () => { + const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-check-')); + const result = runCli(['--check'], cacheRoot); + + assert.deepStrictEqual( + { + status: result.status, + namesMissingBuilds: /Not ready: .*legacy/.test(result.stderr), + // Non-destructive: nothing was materialized under the cache root. + cacheRootUntouched: readdirSync(cacheRoot).length === 0, + }, + { status: 1, namesMissingBuilds: true, cacheRootUntouched: true }, + ); + }); + + test('an unprepared checkpoint fails the run and names the prepare command', () => { + const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-run-')); + const outputDir = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-out-')); + const result = runCli(['--run-backward', '--output-dir', outputDir], cacheRoot); + const summaryPath = join(outputDir, 'backward-compatibility.json'); + const summary = JSON.parse(readFileSync(summaryPath, 'utf8')) as { + outcome: string; + results: readonly { outcome: string; error?: string }[]; + }; + + assert.deepStrictEqual( + { + status: result.status, + summaryWritten: existsSync(summaryPath), + outcome: summary.outcome, + // Every pair is present as a failed row, never absent. + rowOutcomes: summary.results.map(entry => entry.outcome), + everyRowExplains: summary.results.every(entry => (entry.error ?? '').includes('--prepare')), + }, + { + status: 1, + summaryWritten: true, + outcome: 'failed', + rowOutcomes: ['failed', 'failed', 'failed'], + everyRowExplains: true, + }, + ); + }); + + test('a multi-matrix run writes one stable summary per matrix plus an aggregate', () => { + const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-all-')); + const outputDir = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-all-out-')); + // Forward and backward both need a historical checkpoint on at least one + // end, so an empty cache fails every pair at resolution and no build is + // ever launched — which is what keeps this a unit test. Baselines and + // recovery are excluded here precisely because they *would* launch the + // working tree; the live run covers them. + const result = runCli(['--run-forward', '--run-backward', '--output-dir', outputDir], cacheRoot); + const run = JSON.parse(readFileSync(join(outputDir, 'run.json'), 'utf8')) as { + outcome: string; + subset: string; + matrices: readonly { id: string }[]; + }; + + assert.deepStrictEqual( + { + status: result.status, + files: readdirSync(outputDir).filter(name => name.endsWith('.json')).sort(), + outcome: run.outcome, + subset: run.subset, + matrices: run.matrices.map(entry => entry.id), + }, + { + status: 1, + files: ['backward-compatibility.json', 'forward-migration.json', 'run.json'], + outcome: 'failed', + subset: 'full', + matrices: ['forward', 'backward'], + }, + ); + }); + + /** + * A checkpoint can be absent for reasons that are nobody's mistake: a + * shallow clone, a fork, or a checkpoint pinned to a feature-branch-only + * commit that the default branch cannot reach. This file runs in the + * ordinary unit job, which is shallow, so the CLI degrading to its own + * actionable result rather than a raw `git rev-parse` failure is what keeps + * that job green — and is a property worth pinning rather than assuming. + */ + test('an unresolvable checkpoint ref degrades to an actionable result, not a git error', () => { + const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-unresolvable-')); + const listing = runCli(['--list'], cacheRoot); + const check = runCli(['--check'], cacheRoot); + + assert.deepStrictEqual( + { + // `--list` is a status report; an absent checkpoint is data, not + // a crash, so it stays successful. + listStatus: listing.status, + // `--check` reports unreadiness by exiting nonzero. + checkStatus: check.status, + // Neither leaks git's own vocabulary for a missing revision. + mentionsGitFailure: /unknown revision|ambiguous argument|fatal:/.test(listing.stdout + listing.stderr + check.stdout + check.stderr), + // Every historical checkpoint is accounted for by name. + namesEveryCheckpoint: ['legacy', 'predecessor', 'intermediate'].every(id => listing.stdout.includes(id)), + }, + { listStatus: 0, checkStatus: 1, mentionsGitFailure: false, namesEveryCheckpoint: true }, + ); + }); + + test('--pr requires a run command and --json requires a single matrix', () => { + const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-args-')); + + assert.deepStrictEqual( + { + prAlone: runCli(['--pr'], cacheRoot).stderr.includes('combine it with'), + jsonWithAll: runCli(['--run-all', '--json', 'x.json'], cacheRoot).stderr.includes('single matrix'), + buildWithoutBaselines: runCli(['--run-forward', '--build', 'legacy'], cacheRoot).stderr.includes('--run-baselines'), + }, + { prAlone: true, jsonWithAll: true, buildWithoutBaselines: true }, + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.test.ts new file mode 100644 index 00000000000000..078863880f53e7 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.test.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Focused tests for the decision logic of the process-recovery matrix. + * + * The scenarios themselves fork real Agent Hosts and kill them, so they are run + * by `runRecoveryMatrix` rather than by Mocha. What *is* unit-testable — and is + * the part a wrong answer would silently corrupt every live result with — is + * the classifier: it decides which post-crash observations are admissible + * durability gaps and which are recovery defects. + * + * That line is worth testing precisely because a live run is not guaranteed to + * produce every observation shape. A machine with a fast disk may never once + * exhibit a lost rename, so the handling of that shape would otherwise ship + * unexercised and be discovered only by a CI machine that is slower. + */ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { + classifyRecovery, + isRecoveryDefect, + RECOVERY_BOUNDARIES, + RECOVERY_INTEGRATION_PROPOSALS, + RecoveryClassification, + type IRecoveryObservation, + type IRecoveryScenarioResult, +} from './recoveryMatrix.js'; +import { tallyClassifications } from './runRecoveryMatrix.js'; + +const TITLES = { afterMutation: 'Renamed' } as const; + +function classify(observation: IRecoveryObservation): RecoveryClassification { + return classifyRecovery(observation, TITLES); +} + +function scenarioResult(classifications: readonly RecoveryClassification[]): IRecoveryScenarioResult { + return { + scenario: 'test', + build: 'current', + outcome: 'passed', + durationMs: 0, + steps: [], + classifications, + diagnosticsPath: '', + }; +} + +suite('Agent Host recovery matrix', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('a surviving session is admissible whether or not the rename survived with it', () => { + assert.deepStrictEqual( + [ + // Durable: the mutation reached disk before the kill. + classify({ listedCount: 1, listedTitle: 'Renamed', describedTitle: 'Renamed' }), + // The known catalogue-write gap: readable before the kill, gone after. + classify({ listedCount: 1, listedTitle: 'Untitled', describedTitle: 'Untitled' }), + // Surfaces may restore at different rates; either carrying the new + // title is enough to call the mutation durable. + classify({ listedCount: 1, listedTitle: 'Renamed', describedTitle: 'Untitled' }), + classify({ listedCount: 1, listedTitle: 'Untitled', describedTitle: 'Renamed' }), + ], + [ + RecoveryClassification.ConvergedMutated, + RecoveryClassification.ConvergedPreMutation, + RecoveryClassification.ConvergedMutated, + RecoveryClassification.ConvergedMutated, + ], + ); + }); + + test('losing, duplicating or failing to describe a session are recovery defects', () => { + assert.deepStrictEqual( + [ + classify({ listedCount: 0 }), + classify({ listedCount: 2, listedTitle: 'Renamed', describedTitle: 'Renamed' }), + classify({ listedCount: 1, listedTitle: 'Renamed', describeError: 'could not describe session yet' }), + ].map(classification => ({ classification, defect: isRecoveryDefect(classification) })), + [ + { classification: RecoveryClassification.Lost, defect: true }, + { classification: RecoveryClassification.Duplicated, defect: true }, + { classification: RecoveryClassification.Undescribable, defect: true }, + ], + ); + }); + + test('duplication is a defect even when the duplicate carries the expected title', () => { + // Guards the ordering inside the classifier: a duplicated session whose + // entries both look correct must not be mistaken for a clean recovery. + assert.strictEqual( + classify({ listedCount: 3, listedTitle: 'Renamed', describedTitle: 'Renamed' }), + RecoveryClassification.Duplicated, + ); + }); + + test('an empty restored title is a pre-mutation convergence, not an undescribable session', () => { + // A session that describes with no title at all has been recovered; only + // a `subscribe` that never succeeded leaves `describedTitle` undefined. + assert.deepStrictEqual( + [ + classify({ listedCount: 1, describedTitle: '' }), + classify({ listedCount: 1, describeError: 'transient' }), + ], + [RecoveryClassification.ConvergedPreMutation, RecoveryClassification.Undescribable], + ); + }); + + test('the run tallies admissible shapes so a durability gap is visible even when green', () => { + assert.deepStrictEqual( + tallyClassifications([ + scenarioResult([RecoveryClassification.ConvergedMutated, RecoveryClassification.ConvergedPreMutation]), + scenarioResult([RecoveryClassification.ConvergedMutated]), + scenarioResult([]), + ]), + { + [RecoveryClassification.ConvergedMutated]: 2, + [RecoveryClassification.ConvergedPreMutation]: 1, + }, + ); + }); + + test('both admissible shapes are always reported, so a run cannot hide a zero', () => { + // A run in which no rename ever survived must report `0`, not omit the + // key — an omitted key reads as "not measured" rather than "never held". + assert.deepStrictEqual( + tallyClassifications([scenarioResult([])]), + { + [RecoveryClassification.ConvergedMutated]: 0, + [RecoveryClassification.ConvergedPreMutation]: 0, + }, + ); + }); + + test('every uncovered boundary names a scoped integration proposal, and every proposal names an existing-coverage gap', () => { + const uncovered = RECOVERY_BOUNDARIES.filter(boundary => !boundary.covered).map(boundary => boundary.id); + assert.deepStrictEqual( + { + uncovered, + proposed: RECOVERY_INTEGRATION_PROPOSALS.map(proposal => proposal.boundaryId), + // A proposal that does not state what already exists invites + // duplicating a green test instead of closing the real gap. + allStateAGap: RECOVERY_INTEGRATION_PROPOSALS.every(proposal => + proposal.existingCoverage.some(entry => entry.startsWith('Gap:'))), + coveredHaveScenarios: RECOVERY_BOUNDARIES + .filter(boundary => boundary.covered) + .every(boundary => boundary.detail.includes('scenario')), + }, + { + uncovered: ['torn-write-corruption', 'pending-receipt-at-kill'], + proposed: ['torn-write-corruption', 'pending-receipt-at-kill'], + allStateAGap: true, + coveredHaveScenarios: true, + }, + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.ts new file mode 100644 index 00000000000000..9f4bb796cc89f1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.ts @@ -0,0 +1,828 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Live process-recovery scenarios: what survives when the Agent Host is not + * asked to shut down, but simply stops existing. + * + * The same-build restart baseline establishes that a build can reopen its own + * profile after a **graceful** shutdown — stdin closed, flush awaited, process + * exited. That is the easy half. This file covers the half a user actually + * meets: the machine slept and the socket died, the process was OOM-killed, a + * container was reaped, or someone hit the power button mid-rename. + * + * Every scenario here therefore ends its first phase with `SIGKILL`. There is + * no shutdown handshake, no flush, no chance for the host to tidy up; whatever + * reached disk before the signal is the entire inheritance of the next process. + * + * ## Externality is preserved, and it constrains what can be claimed + * + * These scenarios obey the same rule as the rest of the E2E suite: the host is + * reached **only** over AHP on a WebSocket. Nothing here opens the host's + * database, reads its catalogue file, parses its logs for assertions, or + * imports host internals. That rule is what makes a passing result mean + * something — but it also bounds what can honestly be tested, and the bound is + * stated rather than papered over: + * + * - A black-box client can kill the process at an **AHP-observable** boundary + * (a request that has returned, a mutation a readback already reflects). It + * cannot kill it at an *internal* boundary — mid-write, between a receipt + * being queued and being fsynced, or with a deliberately truncated file — + * because it cannot see or create those states from outside. + * - Consequently the exact corruption and pending-receipt boundaries are + * **not** claimed by this file. {@link RECOVERY_BOUNDARIES} records them as + * explicitly out of black-box reach, and they are routed to a separately + * scoped integration test rather than faked with a plausible-looking E2E. + * + * ## Durability is measured, not assumed + * + * The host exposes no durability acknowledgment. `subscribe` and `listSessions` + * are served from memory, so a rename is *readable* long before it is + * *durable*, and the catalogue write is queued fire-and-forget behind them — + * and is not covered by the shutdown flush even when there is one, which after + * `SIGKILL` there is not. + * + * So a scenario that kills at the readback boundary cannot assert "the rename + * survived": that would encode a guarantee the host does not make, and would + * flake as a function of disk speed. What it asserts instead is the property + * that genuinely must hold — **convergence**: + * + * ```text + * admissible after an unclean kill inadmissible, and asserted against + * ──────────────────────────────── ────────────────────────────────── + * session present, new title session missing entirely + * session present, previous title session duplicated + * session present but undescribable + * ``` + * + * Losing the *rename* is a known durability gap. Losing the *session*, or + * growing a second copy of it, is a recovery defect. {@link classifyRecovery} + * draws exactly that line, and the observed side of it is reported in the JSON + * so the durability gap stays visible as data instead of being hidden by a + * tolerant assertion. + * + * ## The scenarios + * + * ```text + * A unclean-kill-restart create ▸ rename ▸ settle ▸ KILL ▸ restart ▸ list+subscribe + * B repeated-unclean-restart (KILL ▸ restart) × N, asserting no duplicates accumulate + * C kill-at-mutation-boundary rename ▸ readback returns ▸ KILL immediately ▸ converge + * D unclean-predecessor-upgrade historical build ▸ KILL ▸ current build on the same profile + * ``` + * + * Scenario D is the one that matters for a migration: it proves the current + * build's upgrade path is entered from state a previous build abandoned + * mid-flight, which is the realistic input to a migration and the one a clean + * hand-off never produces. + * + * All of it runs against the scripted mock provider — tokenless, networkless, + * fixture-free — so the subject is the *host's* recovery rather than a + * provider's replay state. + */ + +import { mkdirSync, mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { timeout } from '../../../../../../base/common/async.js'; +import { join } from '../../../../../../base/common/path.js'; +import { LiveCompatAhpClient } from './agentHostLiveCompatClient.js'; +import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; +import { + createAgentHostCapabilityAdapter, + LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, + type IAgentHostCapabilityAdapter, +} from './agentHostLiveCompatCapabilities.js'; +import { startLiveCompatServer, type ILiveCompatLaunchOptions, type ILiveCompatServerHandle } from './agentHostLiveCompatServer.js'; +import type { + AgentProviderCapabilities, + ILiveCompatInitializeResult, + ILiveCompatSessionList, + ILiveCompatSubscribeResult, +} from './agentHostLiveCompatProtocol.js'; +import type { ILiveCompatStepResult } from './sameBuildRestartBaseline.js'; + +/** Root channel URI. A constant of the protocol, stable across every build. */ +const ROOT_CHANNEL = 'ahp-root://'; +/** Provider the scenarios drive; see the file header for why it is the mock. */ +const PROVIDER = 'mock'; +const PER_CALL_TIMEOUT_MS = 30_000; + +/** Title dispatched before the kill, and looked for after it. */ +const TITLE_BEFORE_KILL = 'Recovery Matrix Renamed'; +/** Title used by scenario C, dispatched at the boundary the kill races. */ +const BOUNDARY_TITLE = 'Recovery Matrix Boundary'; + +/** + * A restored session is not describable the instant the host accepts + * connections: the provider is re-registered and the catalogue re-read + * concurrently with the socket opening. Retrying is part of the client + * contract, but the budget is bounded so a genuinely lost session still fails. + */ +const RESTORE_ATTEMPTS = 20; +const RESTORE_RETRY_DELAY_MS = 500; + +/** + * Settle window used **only** by scenario A, which is the scenario asking + * "does a rename that had time to persist survive an unclean kill?". Scenario C + * deliberately has no settle window — racing that write is its entire subject. + */ +const PERSIST_SETTLE_MS = 1_000; + +/** How many kill/restart cycles scenario B performs. */ +const CONVERGENCE_CYCLES = 3; + +/** + * Where a recovered session landed, relative to the mutation the kill raced. + * + * The first two are admissible outcomes of an unclean kill; the last two are + * recovery defects. Keeping them as one enumeration is what lets a scenario + * both *assert* (no defect) and *report* (which admissible outcome occurred) + * from a single observation. + */ +export const enum RecoveryClassification { + /** The mutation was durable: it survived the kill. */ + ConvergedMutated = 'converged-mutated', + /** The session survived, the mutation did not. A known durability gap. */ + ConvergedPreMutation = 'converged-pre-mutation', + /** The session is gone. A recovery defect. */ + Lost = 'lost', + /** The session came back more than once. A recovery defect. */ + Duplicated = 'duplicated', + /** Listed but not describable within the restore budget. A defect. */ + Undescribable = 'undescribable', +} + +/** What the client observed about one session after a restart. */ +export interface IRecoveryObservation { + /** How many list entries carried the session's resource. */ + readonly listedCount: number; + /** Title on the list entry, when listed. */ + readonly listedTitle?: string; + /** Title from a successful `subscribe`, when it succeeded. */ + readonly describedTitle?: string; + /** Why `subscribe` never succeeded, when it did not. */ + readonly describeError?: string; +} + +/** + * Decide whether a recovery was admissible, and which admissible shape it took. + * + * Pure so it can be tested against every observation shape without launching a + * process — including the shapes a live run is not guaranteed to produce, which + * are precisely the ones whose handling must not be assumed. + */ +export function classifyRecovery( + observation: IRecoveryObservation, + titles: { readonly beforeMutation?: string; readonly afterMutation: string }, +): RecoveryClassification { + if (observation.listedCount > 1) { + return RecoveryClassification.Duplicated; + } + if (observation.listedCount === 0) { + return RecoveryClassification.Lost; + } + if (observation.describedTitle === undefined) { + return RecoveryClassification.Undescribable; + } + // The list entry and the description are two different surfaces over the + // same durable state; the mutation counts as durable when either surface + // reports it, since a build may restore a title to one before the other. + if (observation.describedTitle === titles.afterMutation || observation.listedTitle === titles.afterMutation) { + return RecoveryClassification.ConvergedMutated; + } + return RecoveryClassification.ConvergedPreMutation; +} + +/** Whether a classification is a recovery defect (as opposed to a durability gap). */ +export function isRecoveryDefect(classification: RecoveryClassification): boolean { + return classification === RecoveryClassification.Lost + || classification === RecoveryClassification.Duplicated + || classification === RecoveryClassification.Undescribable; +} + +/** + * A boundary this matrix either covers or explicitly does not. + * + * Recorded as data, and emitted into the run's JSON, so that "what was not + * tested" is a first-class output rather than something a reader has to infer + * from the absence of a scenario. + */ +export interface IRecoveryBoundary { + readonly id: string; + readonly description: string; + readonly covered: boolean; + /** Scenario covering it, or why it is out of black-box reach. */ + readonly detail: string; +} + +export const RECOVERY_BOUNDARIES: readonly IRecoveryBoundary[] = Object.freeze([ + { + id: 'unclean-exit-after-graceful-quiescence', + description: 'Process killed with no shutdown handshake after its writes had time to settle.', + covered: true, + detail: 'scenario unclean-kill-restart', + }, + { + id: 'repeated-unclean-exit', + description: 'Repeated kill/restart cycles converge without accumulating duplicate sessions.', + covered: true, + detail: 'scenario repeated-unclean-restart', + }, + { + id: 'unclean-exit-at-mutation-readback', + description: 'Process killed immediately after a metadata mutation became AHP-observable.', + covered: true, + detail: 'scenario kill-at-mutation-boundary, killed at the readback response boundary with no intervening sleep', + }, + { + id: 'unclean-predecessor-handoff', + description: 'A newer build opens a profile a previous build abandoned without shutting down.', + covered: true, + detail: 'scenario unclean-predecessor-upgrade', + }, + { + id: 'torn-write-corruption', + description: 'Recovery from a partially-written or truncated persistence file.', + covered: false, + detail: 'not reachable over AHP: a black-box client cannot truncate host-owned files, and doing so would violate the suite externality rule. Routed to a scoped integration test — see RECOVERY_INTEGRATION_PROPOSALS.', + }, + { + id: 'pending-receipt-at-kill', + description: 'A write queued but not yet flushed when the process dies.', + covered: false, + detail: 'not reachable over AHP: the queue is internal and the host advertises no durability acknowledgment, so the boundary cannot be observed or targeted from outside. Routed to a scoped integration test — see RECOVERY_INTEGRATION_PROPOSALS.', + }, +]); + +/** + * Boundaries that need host internals, described precisely enough to be + * implemented as integration tests without re-deriving the analysis. + * + * Deliberately data in this file rather than prose in a document: it is + * emitted with the run, so the proposal travels with the evidence that + * motivated it. + */ +export interface IRecoveryIntegrationProposal { + readonly boundaryId: string; + /** Where such a test belongs, given it may import internals. */ + readonly suggestedLocation: string; + /** How to reach the boundary once internals are in scope. */ + readonly approach: string; + /** What the test would assert. */ + readonly assertion: string; + /** + * Coverage that already exists nearby, so the proposal is scoped to the + * genuine gap rather than duplicating a test that is already green. + */ + readonly existingCoverage: readonly string[]; +} + +export const RECOVERY_INTEGRATION_PROPOSALS: readonly IRecoveryIntegrationProposal[] = Object.freeze([ + { + boundaryId: 'torn-write-corruption', + suggestedLocation: 'src/vs/platform/agentHost/test/node/sessionDatabase.test.ts and agentHostDatabase.test.ts — they already own these stores and may import them directly, which an E2E must not.', + approach: 'Open the SQLite database against a temp directory, write a session, close it, then damage the file in place before reopening on the same path: truncate mid-page, zero the header, leave an orphaned -wal/-shm pair with no main database, and set an unknown (future) schema version. Reopen and drive the normal read path.', + assertion: 'Reopening resolves rather than rejecting, undamaged rows are still returned, and a database that cannot be salvaged is quarantined rather than deleted alongside adjacent good state. Each damage shape is its own case so a regression names the shape it broke. The unknown-schema-version case should assert a refusal to downgrade, not a silent reformat.', + existingCoverage: [ + 'sessionDatabase.test.ts:33 — transient initialization failure is retried.', + 'sessionDatabase.test.ts:112-170 — migrations apply, reopen, and roll back on failure.', + 'agentHostDatabase.test.ts:178-870 — schema creation and v4→v6→v8 upgrades, including legacy migration.', + 'agentHostCatalogListReader.test.ts:167 — unusable catalogue rows fall back rather than throwing.', + 'sessionArtifacts.test.ts:87 — malformed JSON input is handled.', + 'Gap: none of these damage the SQLite file itself; all corruption coverage today is at the row/JSON level.', + ], + }, + { + boundaryId: 'pending-receipt-at-kill', + suggestedLocation: 'src/vs/platform/agentHost/test/node/sessionDatabase.test.ts (write tracking) and agentHostCatalogSyncService.test.ts (pending receipts) — both already instantiate the machinery this boundary lives in.', + approach: 'Issue a mutation and, deliberately **without** awaiting `SessionDatabase.whenIdle()`, open a second database over the same path — modelling the process dying between a fire-and-forget write being tracked in `_pendingWrites` and the query completing. For the catalogue, hold a pending payload unflushed and reopen. The existing `_track` seam makes this deterministic without a sleep, which is exactly what a black-box client cannot achieve.', + assertion: 'The second reader observes either the pre-mutation or the post-mutation state and never a partial or duplicated one — the same convergence contract this E2E matrix asserts from outside, pinned here at the boundary the E2E cannot target. Pair it with a test asserting that skipping `whenIdle()` is the *only* way to lose the write, which turns the current comment at sessionDatabase.ts:358 into an executable claim.', + existingCoverage: [ + 'sessionDatabase.test.ts:583-609 — fire-and-forget writes and truncation, usage tracking.', + 'sessionDatabase.test.ts:669-719 — dispose behaviour, including dispose-during-open.', + 'sessionDatabase.ts:1065-1069 — `whenIdle()` drains `_pendingWrites`; :1083-1089 — `_track()` wraps public mutators.', + 'agentHostCatalogSyncService.test.ts:172,180,253,361 — local/central write failure, concurrent conflict, and queued mutations retaining caller payloads.', + 'Gap: every case above exercises the graceful path where `whenIdle()` is awaited; none models the process disappearing while `_pendingWrites` is non-empty.', + ], + }, +]); + +/** Machine-readable result of one recovery scenario. */ +export interface IRecoveryScenarioResult { + readonly scenario: string; + readonly build: string; + readonly buildDescription?: string; + /** Second build, for scenarios that hand a profile between builds. */ + readonly secondBuild?: string; + readonly outcome: 'passed' | 'failed'; + readonly durationMs: number; + readonly protocolVersion?: string; + readonly steps: readonly ILiveCompatStepResult[]; + /** How each restart classified; the durability gap is visible here. */ + readonly classifications: readonly RecoveryClassification[]; + /** Retained directory holding home, user-data (host logs) and workspace. */ + readonly diagnosticsPath: string; + readonly error?: string; +} + +export interface IRecoveryScenarioOptions { + readonly diagnosticsRoot?: string; + readonly env?: Readonly>; +} + +/** Records step outcomes and their durations in performance order. */ +class StepRecorder { + private readonly _steps: ILiveCompatStepResult[] = []; + + get steps(): readonly ILiveCompatStepResult[] { + return this._steps; + } + + async run(name: string, body: () => Promise): Promise { + const startedAt = Date.now(); + try { + const result = await body(); + this._steps.push({ name, outcome: 'passed', durationMs: Date.now() - startedAt }); + return result; + } catch (error) { + this._steps.push({ name, outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }); + throw error; + } + } + + note(name: string, detail: string): void { + this._steps.push({ name, outcome: 'passed', durationMs: 0, detail }); + } + + skip(name: string, reason: string): void { + this._steps.push({ name, outcome: 'skipped', durationMs: 0, detail: reason }); + } +} + +/** + * The scenario driver: one profile, a client that can be reconnected, and a + * process that can be killed rather than asked to leave. + * + * Exists so the four scenarios differ only in *when* they kill and *what* they + * assert afterwards, instead of each re-deriving launch/connect/kill. + */ +class RecoverySession { + private _server: ILiveCompatServerHandle | undefined; + private _client: LiveCompatAhpClient | undefined; + private _clientSeq = 0; + protocolVersion: string | undefined; + + constructor( + private readonly _launch: ILiveCompatLaunchOptions, + private readonly _clientIdPrefix: string, + ) { } + + get client(): LiveCompatAhpClient { + if (!this._client) { + throw new Error('[agent-host-recovery] no live connection; the host is not running'); + } + return this._client; + } + + /** Launch a build on this profile and complete the AHP handshake. */ + async start(phase: string, overrides?: Partial): Promise { + this._server = await startLiveCompatServer({ ...this._launch, ...overrides }); + const client = new LiveCompatAhpClient(this._server.port); + await client.connect(); + this._client = client; + const initialize = await client.call('initialize', { + channel: ROOT_CHANNEL, + protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], + clientId: `${this._clientIdPrefix}-${phase}`, + }, PER_CALL_TIMEOUT_MS); + this.protocolVersion = initialize.protocolVersion; + return createAgentHostCapabilityAdapter({ + protocolVersion: initialize.protocolVersion, + providerCapabilities: await this._readProviderCapabilities(client), + }); + } + + /** + * Kill the host outright and wait for the process to be reaped. + * + * `SIGKILL` rather than `SIGTERM` or closing stdin, and that choice is the + * point of this file: the host gets no handler, no flush and no chance to + * write a clean marker, which is exactly the state a crash leaves behind. + * + * Awaiting the exit is load-bearing for a different reason — the next phase + * reopens the same user-data directory, and a not-yet-reaped predecessor + * would still hold it, turning a recovery result into a race. + */ + async kill(): Promise { + const child = this._server?.process; + this._client?.close(); + this._client = undefined; + this._server = undefined; + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + const exited = new Promise(resolve => child.once('exit', () => resolve())); + child.kill('SIGKILL'); + await exited; + } + + /** Best-effort teardown for the failure path. */ + async dispose(): Promise { + await this.kill().catch(() => undefined); + } + + /** Create a session and confirm it is subscribable before returning. */ + async createSession(uri: string): Promise { + await this.client.call('createSession', { channel: uri, provider: PROVIDER }, PER_CALL_TIMEOUT_MS); + await this.client.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); + } + + /** + * Dispatch a rename and return once a readback reflects it. + * + * The returned promise settling **is** the boundary scenario C kills at: a + * response the host has already produced, not an elapsed duration. That is + * what makes the race deterministic in the only sense available from + * outside — the kill provably lands after the reducer ran, and provably + * without waiting for anything else. + */ + async renameAndAwaitReadback(uri: string, title: string): Promise { + this.client.notify('dispatchAction', { + channel: uri, + clientSeq: ++this._clientSeq, + action: { type: 'session/titleChanged', title }, + }); + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + const subscribed = await this.client.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); + if (subscribed.snapshot?.state?.title === title) { + return; + } + await timeout(RESTORE_RETRY_DELAY_MS); + } + throw new Error(`[agent-host-recovery] '${title}' was never observable on ${uri} before the kill`); + } + + /** Observe a session across both surfaces, tolerating the describe window. */ + async observe(uri: string): Promise { + const listed = await this.client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + const matches = (listed.items ?? []).filter(item => item.resource === uri); + if (matches.length === 0) { + return { listedCount: 0 }; + } + let describedTitle: string | undefined; + let describeError: string | undefined; + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + try { + const subscribed = await this.client.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); + describedTitle = subscribed.snapshot?.state?.title ?? ''; + describeError = undefined; + break; + } catch (error) { + describeError = messageOf(error); + await timeout(RESTORE_RETRY_DELAY_MS); + } + } + return { listedCount: matches.length, listedTitle: matches[0].title, describedTitle, describeError }; + } + + /** Read provider capabilities off the root snapshot, as any client would. */ + private async _readProviderCapabilities(client: LiveCompatAhpClient): Promise> { + const root = await client.call('subscribe', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + const capabilities = new Map(); + for (const agent of root.snapshot?.state?.agents ?? []) { + capabilities.set(agent.provider, agent.capabilities ?? {}); + } + return capabilities; + } +} + +/** + * Scenario A — a crash after the host had reached quiescence. + * + * The mildest unclean exit there is, and therefore the one whose failure is + * least ambiguous: the rename was given time to reach disk, so anything missing + * afterwards was lost by recovery rather than by the race. + */ +export async function runUncleanKillRestart( + build: IPreparedAgentHostBuild, + options: IRecoveryScenarioOptions = {}, +): Promise { + return runScenario('unclean-kill-restart', build, options, async (session, recorder, context) => { + const adapter = await recorder.run('launch-and-initialize', () => session.start('seed')); + const uri = context.sessionUri; + + await recorder.run('create-session', () => session.createSession(uri)); + await renameStep(recorder, session, adapter, uri, TITLE_BEFORE_KILL); + + await recorder.run('settle-writes', async () => { + // Scenario A's question is about recovery, not about racing the + // catalogue write, so the write is deliberately given time. The + // race itself is scenario C's subject. + await timeout(PERSIST_SETTLE_MS); + }); + + await recorder.run('sigkill', () => session.kill()); + await recorder.run('relaunch', () => session.start('verify', { env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri } })); + + const observation = await recorder.run('observe-recovered', () => session.observe(uri)); + const classification = classifyRecovery(observation, { afterMutation: TITLE_BEFORE_KILL }); + recorder.note('classify', `${classification} (${describeObservation(observation)})`); + assertNoDefect(classification, observation, 'after an unclean kill that followed a settled rename'); + if (adapter.supportsSessionRename && classification === RecoveryClassification.ConvergedPreMutation) { + // Reported, not failed: durability of the catalogue write is a + // host-side gap this suite measures rather than legislates. + recorder.note('durability-gap', `the rename was readable before the kill but did not survive it; observed title '${observation.describedTitle ?? ''}'`); + } + return [classification]; + }); +} + +/** + * Scenario B — repeated crashes must converge, not accumulate. + * + * One kill/restart proves recovery works once. The failure mode this scenario + * exists for is the one that only appears when recovery runs against state a + * previous recovery produced: a restored session written back as a *new* entry, + * so each crash leaves the profile with one more copy of the same session. + * Nothing in a single-cycle test can see that. + */ +export async function runRepeatedUncleanRestart( + build: IPreparedAgentHostBuild, + options: IRecoveryScenarioOptions = {}, +): Promise { + return runScenario('repeated-unclean-restart', build, options, async (session, recorder, context) => { + const adapter = await recorder.run('launch-and-initialize', () => session.start('seed')); + const uri = context.sessionUri; + await recorder.run('create-session', () => session.createSession(uri)); + // Renamed before the first kill so each cycle's classification is a real + // verdict. Without a mutation to compare against, every cycle would + // trivially report "pre-mutation" and the tally would look like a + // durability failure that never happened. + await renameStep(recorder, session, adapter, uri, TITLE_BEFORE_KILL); + await recorder.run('settle-writes', () => timeout(PERSIST_SETTLE_MS)); + + const classifications: RecoveryClassification[] = []; + for (let cycle = 1; cycle <= CONVERGENCE_CYCLES; cycle++) { + await recorder.run(`sigkill-${cycle}`, () => session.kill()); + await recorder.run(`relaunch-${cycle}`, () => session.start(`cycle-${cycle}`, { + env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri }, + })); + const observation = await recorder.run(`observe-${cycle}`, () => session.observe(uri)); + const classification = classifyRecovery(observation, { afterMutation: TITLE_BEFORE_KILL }); + recorder.note(`classify-${cycle}`, `${classification} (${describeObservation(observation)})`); + // The duplicate check is the load-bearing assertion: it is why the + // scenario loops instead of killing once. + assertNoDefect(classification, observation, `on unclean restart cycle ${cycle} of ${CONVERGENCE_CYCLES}`); + classifications.push(classification); + } + return classifications; + }); +} + +/** + * Scenario C — kill at the moment a mutation becomes observable. + * + * The kill is issued as the next statement after the readback resolves: no + * sleep, no polling interval, nothing that makes the timing a function of the + * machine. The host has demonstrably reduced the action (the readback proves + * it) and has demonstrably not been given time to do anything else. + * + * Both outcomes are admissible and both are recorded. What is asserted is only + * that the *session* survives the race intact — the property that must hold + * regardless of where the write landed. + */ +export async function runKillAtMutationBoundary( + build: IPreparedAgentHostBuild, + options: IRecoveryScenarioOptions = {}, +): Promise { + return runScenario('kill-at-mutation-boundary', build, options, async (session, recorder, context) => { + const adapter = await recorder.run('launch-and-initialize', () => session.start('seed')); + const uri = context.sessionUri; + await recorder.run('create-session', () => session.createSession(uri)); + await recorder.run('settle-session-creation', () => timeout(PERSIST_SETTLE_MS)); + + if (!adapter.supportsSessionRename) { + recorder.skip('kill-at-boundary', `negotiated protocol ${adapter.protocolVersion} predates client-dispatchable session/titleChanged`); + await recorder.run('sigkill', () => session.kill()); + await recorder.run('relaunch', () => session.start('verify', { env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri } })); + const plain = await recorder.run('observe-recovered', () => session.observe(uri)); + const plainClassification = classifyRecovery(plain, { afterMutation: BOUNDARY_TITLE }); + assertNoDefect(plainClassification, plain, 'after an unclean kill on a build without dispatchable rename'); + return [plainClassification]; + } + + await recorder.run('mutate-and-kill-at-readback', async () => { + await session.renameAndAwaitReadback(uri, BOUNDARY_TITLE); + // Immediately, on purpose: this adjacency is the experiment. + await session.kill(); + }); + + await recorder.run('relaunch', () => session.start('verify', { env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri } })); + const observation = await recorder.run('observe-recovered', () => session.observe(uri)); + const classification = classifyRecovery(observation, { afterMutation: BOUNDARY_TITLE }); + recorder.note('classify', `${classification} (${describeObservation(observation)})`); + recorder.note('boundary-outcome', classification === RecoveryClassification.ConvergedMutated + ? 'the mutation was already durable when the process died' + : 'the mutation was readable but not yet durable when the process died — the known catalogue-write gap, observed'); + assertNoDefect(classification, observation, 'after a kill at the mutation readback boundary'); + return [classification]; + }); +} + +/** + * Scenario D — a newer build inherits a profile nobody closed. + * + * A migration's real input is not a tidy profile handed over by a graceful + * shutdown; it is whatever a previous build left when it stopped. This scenario + * produces exactly that input — historical build, `SIGKILL`, current build on + * the same directories — and asserts the upgrade path survives it. + * + * It is the only scenario spanning two builds, so it is also the only one whose + * failure could mean either "recovery is broken" or "migration is broken"; the + * per-phase steps and the retained diagnostics directory are what separate the + * two after the fact. + */ +export async function runUncleanPredecessorUpgrade( + historical: IPreparedAgentHostBuild, + current: IPreparedAgentHostBuild, + options: IRecoveryScenarioOptions = {}, +): Promise { + const startedAt = Date.now(); + const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-recovery-upgrade-${historical.id}-to-${current.id}-`)); + const dirs = createPersistentDirectories(diagnosticsPath); + const recorder = new StepRecorder(); + const uri = `${PROVIDER}:/recovery-upgrade-${Date.now()}`; + const classifications: RecoveryClassification[] = []; + + const base = { homeDir: dirs.homeDir, userDataDir: dirs.userDataDir, env: options.env }; + const predecessor = new RecoverySession({ ...base, serverEntry: historical.serverEntry }, `recovery-${historical.id}`); + const successor = new RecoverySession({ ...base, serverEntry: current.serverEntry }, `recovery-${current.id}`); + let protocolVersion: string | undefined; + + try { + const adapter = await recorder.run('launch-historical', () => predecessor.start('seed')); + recorder.note('historical-protocol', `${historical.id} negotiated ${adapter.protocolVersion}`); + await recorder.run('create-session-on-historical', () => predecessor.createSession(uri)); + await renameStep(recorder, predecessor, adapter, uri, TITLE_BEFORE_KILL); + await recorder.run('settle-writes', () => timeout(PERSIST_SETTLE_MS)); + // No shutdown handshake: the successor must enter migration from state + // the predecessor abandoned, which is the whole point of the scenario. + await recorder.run('sigkill-historical', () => predecessor.kill()); + + await recorder.run('launch-current-on-abandoned-profile', async () => { + await successor.start('verify', { env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri } }); + }); + protocolVersion = successor.protocolVersion; + + const observation = await recorder.run('observe-migrated', () => successor.observe(uri)); + const classification = classifyRecovery(observation, { afterMutation: TITLE_BEFORE_KILL }); + recorder.note('classify', `${classification} (${describeObservation(observation)})`); + assertNoDefect(classification, observation, `after ${current.id} opened a profile ${historical.id} abandoned without shutting down`); + classifications.push(classification); + + return { + scenario: 'unclean-predecessor-upgrade', + build: historical.id, + buildDescription: historical.description, + secondBuild: current.id, + outcome: 'passed', + durationMs: Date.now() - startedAt, + protocolVersion, + steps: recorder.steps, + classifications, + diagnosticsPath, + }; + } catch (error) { + return { + scenario: 'unclean-predecessor-upgrade', + build: historical.id, + buildDescription: historical.description, + secondBuild: current.id, + outcome: 'failed', + durationMs: Date.now() - startedAt, + protocolVersion, + steps: recorder.steps, + classifications, + diagnosticsPath, + error: messageOf(error), + }; + } finally { + await predecessor.dispose(); + await successor.dispose(); + } +} + +interface IScenarioContext { + readonly sessionUri: string; +} + +/** + * Shared scaffolding for the single-build scenarios. + * + * Never throws for a scenario failure: a failed build is data the caller needs + * alongside the builds that passed, so the failure is reported in the result. + */ +async function runScenario( + scenario: string, + build: IPreparedAgentHostBuild, + options: IRecoveryScenarioOptions, + body: (session: RecoverySession, recorder: StepRecorder, context: IScenarioContext) => Promise, +): Promise { + const startedAt = Date.now(); + const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-recovery-${scenario}-${build.id}-`)); + const dirs = createPersistentDirectories(diagnosticsPath); + const recorder = new StepRecorder(); + const session = new RecoverySession({ + serverEntry: build.serverEntry, + homeDir: dirs.homeDir, + userDataDir: dirs.userDataDir, + env: options.env, + }, `recovery-${build.id}`); + const context: IScenarioContext = { sessionUri: `${PROVIDER}:/recovery-${scenario}-${build.id}-${Date.now()}` }; + + try { + const classifications = await body(session, recorder, context); + return { + scenario, + build: build.id, + buildDescription: build.description, + outcome: 'passed', + durationMs: Date.now() - startedAt, + protocolVersion: session.protocolVersion, + steps: recorder.steps, + classifications, + diagnosticsPath, + }; + } catch (error) { + return { + scenario, + build: build.id, + buildDescription: build.description, + outcome: 'failed', + durationMs: Date.now() - startedAt, + protocolVersion: session.protocolVersion, + steps: recorder.steps, + classifications: [], + diagnosticsPath, + error: messageOf(error), + }; + } finally { + await session.dispose(); + } +} + +/** + * Rename, or record why the build cannot be asked to. + * + * Skipped rather than omitted: a build too old to dispatch a rename still runs + * the recovery scenario, and stating the omission keeps the result's coverage + * honest instead of silently narrower than it looks. + */ +async function renameStep( + recorder: StepRecorder, + session: RecoverySession, + adapter: IAgentHostCapabilityAdapter, + uri: string, + title: string, +): Promise { + if (!adapter.supportsSessionRename) { + recorder.skip('rename-session', `negotiated protocol ${adapter.protocolVersion} predates client-dispatchable session/titleChanged`); + return; + } + await recorder.run('rename-session', () => session.renameAndAwaitReadback(uri, title)); +} + +function assertNoDefect(classification: RecoveryClassification, observation: IRecoveryObservation, context: string): void { + if (isRecoveryDefect(classification)) { + throw new Error(`[agent-host-recovery] ${classification} ${context}: ${describeObservation(observation)}`); + } +} + +function describeObservation(observation: IRecoveryObservation): string { + const parts = [`listed=${observation.listedCount}`]; + if (observation.listedTitle !== undefined) { + parts.push(`listedTitle='${observation.listedTitle}'`); + } + if (observation.describedTitle !== undefined) { + parts.push(`describedTitle='${observation.describedTitle}'`); + } + if (observation.describeError !== undefined) { + parts.push(`describeError=${observation.describeError}`); + } + return parts.join(', '); +} + +function createPersistentDirectories(root: string): { homeDir: string; userDataDir: string } { + const homeDir = join(root, 'home'); + const userDataDir = join(root, 'user-data'); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(join(homeDir, '.codex'), { recursive: true }); + mkdirSync(userDataDir, { recursive: true }); + mkdirSync(join(root, 'workspace'), { recursive: true }); + return { homeDir, userDataDir }; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/runBackwardCompatibilityMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/runBackwardCompatibilityMatrix.ts new file mode 100644 index 00000000000000..2dee796a53c0c8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/runBackwardCompatibilityMatrix.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Entry point that resolves checkpoints and runs the backward-compatibility + * round trips. + * + * Split from {@link backwardCompatibilityMatrix} so the scenario itself takes + * already-prepared builds and knows nothing about the repository, git, or the + * build cache. This module is the only place the two meet, and it inherits the + * matrix runner's governing rule: + * + * - **A pairing is never silently skipped.** A checkpoint that cannot be + * resolved becomes a *failed* result carrying the resolver's own explanation + * (which names the exact `--prepare` command to run), so a run that covered + * two of three pairings can never be mistaken for a run that covered three. + */ + +import { runBackwardCompatibilityRoundTrip, unresolvedBackwardCompatResult, type IBackwardCompatMatrixSummary, type IBackwardCompatScenarioResult } from './backwardCompatibilityMatrix.js'; +import { AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; +import { resolveBuild, type ILiveCompatMatrixOptions } from './agentHostLiveCompatMatrix.js'; +import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; + +/** + * Older checkpoints the current build is handed down to, oldest first. + * + * Ordering is deliberate: the oldest build is the most likely to fail, and + * running it first means the longest-standing incompatibility is reported + * before time is spent on the closer ones. + */ +export const BACKWARD_COMPAT_OLDER_BUILDS: readonly string[] = Object.freeze([ + AgentHostBuildId.Legacy, + AgentHostBuildId.Intermediate, + AgentHostBuildId.Predecessor, +]); + +/** + * Run `current → older → current → restart` for each requested older build. + * + * Builds run sequentially; see {@link runBackwardCompatibilityMatrix} for why. + */ +export async function runBackwardCompatibilityMatrixForBuilds( + olderBuildIds: readonly string[], + options: ILiveCompatMatrixOptions, +): Promise { + const startedAt = Date.now(); + const results: IBackwardCompatScenarioResult[] = []; + + let current: IPreparedAgentHostBuild | undefined; + let currentProblem: string | undefined; + try { + current = resolveBuild(AgentHostBuildId.Current, options); + } catch (error) { + currentProblem = messageOf(error); + } + + for (const olderBuildId of olderBuildIds) { + if (!current) { + // Every pairing needs the current build, so its absence fails them + // all rather than aborting the run with a single opaque throw. + results.push(unresolvedBackwardCompatResult(AgentHostBuildId.Current, olderBuildId, currentProblem!)); + continue; + } + let older: IPreparedAgentHostBuild; + try { + older = resolveBuild(olderBuildId, options); + } catch (error) { + results.push(unresolvedBackwardCompatResult(current.id, olderBuildId, messageOf(error))); + continue; + } + results.push(await runBackwardCompatibilityRoundTrip(current, older, { diagnosticsRoot: options.diagnosticsRoot })); + } + + return { + suite: 'agent-host-live-compat/backward-compatibility-round-trip', + startedAt: new Date(startedAt).toISOString(), + durationMs: Date.now() - startedAt, + outcome: results.every(entry => entry.outcome === 'passed') ? 'passed' : 'failed', + results, + }; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/runForwardMigrationMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/runForwardMigrationMatrix.ts new file mode 100644 index 00000000000000..128ca7baecb244 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/runForwardMigrationMatrix.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Entry point for running the forward-migration matrix. + * + * The split from {@link forwardMigrationMatrix} is deliberate. That module + * knows how to drive two *already resolved* builds and nothing else, which is + * what makes it testable without a prepared cache. This module owns the messy + * outside world: turning checkpoint ids into launchable builds, deciding what + * pairs constitute "forward", and shaping a summary for a caller. + * + * Two rules carry over from the baseline matrix and are restated because they + * are properties of the *result*, not of the code: + * + * - **A pair is never silently skipped.** A checkpoint that cannot be resolved + * is reported as a failed entry carrying the resolver's own explanation + * (which names the exact `--prepare` command to run), so a run that covered + * two of three upgrades can never be mistaken for a run that covered three. + * - **Pairs run sequentially**, for the same attributability reason. + */ + +import { AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; +import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; +import { resolveBuild, type ILiveCompatMatrixOptions } from './agentHostLiveCompatMatrix.js'; +import { runForwardMigrationScenario, type IForwardMigrationSummary } from './forwardMigrationMatrix.js'; +import type { ILiveCompatScenarioResult } from './sameBuildRestartBaseline.js'; + +/** + * The source checkpoints, oldest first. Every one of them upgrades to the + * working tree, which is the only build a forward claim can be *about*. + */ +export const FORWARD_MIGRATION_SOURCES: readonly string[] = Object.freeze([ + AgentHostBuildId.Legacy, + AgentHostBuildId.Predecessor, + AgentHostBuildId.Intermediate, +]); + +export interface IRunForwardMigrationOptions extends ILiveCompatMatrixOptions { + /** Source checkpoints to upgrade from. Defaults to all three. */ + readonly sources?: readonly string[]; + /** + * Also run each pair with several sessions in the profile. + * + * Kept opt-out rather than opt-in: a single-session upgrade cannot detect a + * migration that preserves one row but conflates identities across a set, + * and that is a realistic failure mode. + */ + readonly includeMultiSession?: boolean; + /** How many sessions the multi-session variant seeds. */ + readonly multiSessionCount?: number; +} + +/** + * Run every forward-migration pair and summarize the outcome. + */ +export async function runForwardMigrations(options: IRunForwardMigrationOptions): Promise { + const startedAt = Date.now(); + const sources = options.sources ?? FORWARD_MIGRATION_SOURCES; + const includeMultiSession = options.includeMultiSession ?? true; + const multiSessionCount = options.multiSessionCount ?? 3; + const results: ILiveCompatScenarioResult[] = []; + + // Resolved once: a missing working tree is a property of the run, not of + // each pair, and re-resolving it per pair would repeat the same message + // three times while hiding that they share a single cause. + let target: IPreparedAgentHostBuild | undefined; + let targetError: string | undefined; + try { + target = resolveBuild(AgentHostBuildId.Current, options); + } catch (error) { + targetError = messageOf(error); + } + + for (const sourceId of sources) { + let source: IPreparedAgentHostBuild | undefined; + let sourceError: string | undefined; + try { + source = resolveBuild(sourceId, options); + } catch (error) { + sourceError = messageOf(error); + } + + const unresolved = sourceError ?? targetError; + if (unresolved || !source || !target) { + results.push(unresolvedResult(sourceId, unresolved ?? 'build could not be resolved', startedAt)); + continue; + } + + results.push(await runForwardMigrationScenario(source, target, { + diagnosticsRoot: options.diagnosticsRoot, + scenarioSuffix: 'single-session', + })); + if (includeMultiSession) { + results.push(await runForwardMigrationScenario(source, target, { + diagnosticsRoot: options.diagnosticsRoot, + sessionCount: multiSessionCount, + scenarioSuffix: 'multi-session', + })); + } + } + + return { + suite: 'agent-host-live-compat/forward-migration', + startedAt: new Date(startedAt).toISOString(), + durationMs: Date.now() - startedAt, + outcome: results.every(result => result.outcome === 'passed') ? 'passed' : 'failed', + results, + }; +} + +function unresolvedResult(sourceId: string, detail: string, startedAt: number): ILiveCompatScenarioResult { + return { + scenario: 'forward-migration', + build: `${sourceId}->${AgentHostBuildId.Current}`, + outcome: 'failed', + durationMs: Date.now() - startedAt, + steps: [{ name: 'resolve-build', outcome: 'failed', durationMs: 0, detail }], + diagnosticsPath: '', + error: detail, + }; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/runRecoveryMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/runRecoveryMatrix.ts new file mode 100644 index 00000000000000..fe439f9d205636 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/runRecoveryMatrix.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Executes the process-recovery matrix and summarizes the outcome. + * + * The same two rules that govern the restart-baseline matrix apply here, for + * the same reasons: + * + * - **A build is never silently skipped.** A checkpoint that cannot be resolved + * is reported as a failed entry carrying the resolver's own explanation, so a + * run that covered three builds can never be mistaken for one that covered + * four. + * - **Scenarios run sequentially.** Each forks a real Agent Host and kills it + * with `SIGKILL`; overlapping two of those on one machine would make a + * failure attributable to contention rather than to recovery. + * + * A third rule is specific to this matrix: the summary carries + * {@link RECOVERY_BOUNDARIES} and {@link RECOVERY_INTEGRATION_PROPOSALS} + * verbatim. A recovery run's most misreadable property is its *scope*, so what + * was deliberately not covered ships inside the same artifact as what passed, + * rather than living in a document that can drift away from the evidence. + */ + +import { agentHostLiveCompatBuild, AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; +import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; +import { resolveBuild, type ILiveCompatMatrixOptions } from './agentHostLiveCompatMatrix.js'; +import { + RECOVERY_BOUNDARIES, + RECOVERY_INTEGRATION_PROPOSALS, + RecoveryClassification, + runKillAtMutationBoundary, + runRepeatedUncleanRestart, + runUncleanKillRestart, + runUncleanPredecessorUpgrade, + type IRecoveryBoundary, + type IRecoveryIntegrationProposal, + type IRecoveryScenarioResult, +} from './recoveryMatrix.js'; + +/** Aggregate outcome of one recovery run. */ +export interface IRecoveryMatrixSummary { + readonly suite: string; + readonly startedAt: string; + readonly durationMs: number; + readonly outcome: 'passed' | 'failed'; + readonly results: readonly IRecoveryScenarioResult[]; + /** + * Tally of admissible recovery shapes across every restart performed. + * + * This is where the catalogue-write durability gap becomes visible as a + * number instead of an anecdote: a run that is green but whose renames + * never survive reports it here rather than looking indistinguishable from + * a run where durability held. + */ + readonly classificationCounts: Readonly>; + readonly boundaries: readonly IRecoveryBoundary[]; + readonly integrationProposals: readonly IRecoveryIntegrationProposal[]; +} + +export interface IRecoveryMatrixOptions extends ILiveCompatMatrixOptions { + /** + * Build the current-vs-historical upgrade scenario hands a profile from. + * Defaults to the predecessor checkpoint, the closest realistic upgrade. + */ + readonly upgradeFromBuildId?: AgentHostBuildId | string; +} + +/** + * Run every recovery scenario for each requested checkpoint, in order. + * + * The cross-build upgrade scenario is run once at the end rather than per + * build: it is a property of a *pair*, and running it for each requested build + * against itself would assert nothing a single-build scenario has not already. + */ +export async function runRecoveryMatrix( + buildIds: readonly (AgentHostBuildId | string)[], + options: IRecoveryMatrixOptions, +): Promise { + const startedAt = Date.now(); + const results: IRecoveryScenarioResult[] = []; + + for (const buildId of buildIds) { + const resolution = tryResolve(buildId, options); + if (!resolution.build) { + // One entry per scenario the build was going to run, so a resolution + // failure cannot shrink the apparent size of the matrix. + results.push( + failedResolution('unclean-kill-restart', buildId, resolution.error), + failedResolution('repeated-unclean-restart', buildId, resolution.error), + failedResolution('kill-at-mutation-boundary', buildId, resolution.error), + ); + continue; + } + const scenarioOptions = { diagnosticsRoot: options.diagnosticsRoot }; + results.push(await runUncleanKillRestart(resolution.build, scenarioOptions)); + results.push(await runRepeatedUncleanRestart(resolution.build, scenarioOptions)); + results.push(await runKillAtMutationBoundary(resolution.build, scenarioOptions)); + } + + results.push(await runUpgradeScenario(options)); + + return { + suite: 'agent-host-live-compat/recovery-matrix', + startedAt: new Date(startedAt).toISOString(), + durationMs: Date.now() - startedAt, + outcome: results.every(result => result.outcome === 'passed') ? 'passed' : 'failed', + results, + classificationCounts: tallyClassifications(results), + boundaries: RECOVERY_BOUNDARIES, + integrationProposals: RECOVERY_INTEGRATION_PROPOSALS, + }; +} + +/** + * Hand a profile from a historical build, killed uncleanly, to the current one. + * + * Both ends must resolve for the scenario to mean anything, so a failure to + * resolve either is reported as the scenario failing rather than as an absence. + */ +async function runUpgradeScenario(options: IRecoveryMatrixOptions): Promise { + const fromId = options.upgradeFromBuildId ?? AgentHostBuildId.Predecessor; + const from = tryResolve(fromId, options); + const to = tryResolve(AgentHostBuildId.Current, options); + if (!from.build || !to.build) { + const error = from.build ? to.error : from.error; + return failedResolution('unclean-predecessor-upgrade', fromId, error); + } + return runUncleanPredecessorUpgrade(from.build, to.build, { diagnosticsRoot: options.diagnosticsRoot }); +} + +/** Count each admissible recovery shape observed across the whole run. */ +export function tallyClassifications(results: readonly IRecoveryScenarioResult[]): Readonly> { + const counts: Record = { + [RecoveryClassification.ConvergedMutated]: 0, + [RecoveryClassification.ConvergedPreMutation]: 0, + }; + for (const result of results) { + for (const classification of result.classifications) { + counts[classification] = (counts[classification] ?? 0) + 1; + } + } + return counts; +} + +function tryResolve( + buildId: AgentHostBuildId | string, + options: ILiveCompatMatrixOptions, +): { build?: IPreparedAgentHostBuild; error: string } { + try { + // Validates the id against the known checkpoints before planning, so an + // unknown id reports as such rather than as a missing build directory. + agentHostLiveCompatBuild(buildId); + return { build: resolveBuild(buildId, options), error: '' }; + } catch (error) { + return { error: messageOf(error) }; + } +} + +function failedResolution(scenario: string, buildId: AgentHostBuildId | string, error: string): IRecoveryScenarioResult { + return { + scenario, + build: String(buildId), + outcome: 'failed', + durationMs: 0, + steps: [{ name: 'resolve-build', outcome: 'failed', durationMs: 0, detail: error }], + classifications: [], + diagnosticsPath: '', + error, + }; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/sameBuildRestartBaseline.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/sameBuildRestartBaseline.ts new file mode 100644 index 00000000000000..c20242921a4fb6 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/liveCompat/sameBuildRestartBaseline.ts @@ -0,0 +1,403 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The same-build restart baseline. + * + * Before any cross-version claim can mean anything, each checkpoint has to be + * shown to seed and reopen **its own** persistent profile. Otherwise a failure + * in a later upgrade or downgrade matrix is ambiguous: it could be a migration + * defect, or it could be that the build never round-tripped its own state. + * This scenario removes that ambiguity, one build at a time. + * + * Shape of a run, all of it over AHP against a real forked server process: + * + * ```text + * phase 1 (seed) restart, same build phase 2 (verify) + * initialize ─▶ list ─▶ create ─▶ rename list ─▶ subscribe + * (empty) (session + title survive) + * ``` + * + * Two properties are load-bearing: + * + * - **Same directories.** Both phases receive the identical home, user-data and + * workspace directories. The restart is the whole point; a fresh profile + * would make every assertion vacuous. + * - **External.** Nothing here imports host internals, reads the host database, + * or inspects logs for assertions. Contract evolution between builds is + * resolved through {@link IAgentHostCapabilityAdapter}, from what the build + * advertises — never from the checkpoint id. + * + * The scenario runs against the scripted mock provider. That is a deliberate + * choice, not a convenience: a bundled provider (Copilot/Claude/Codex) cannot + * re-describe a session after a restart until it has been materialized by a + * real model-backed turn, which would make an otherwise host-only baseline + * depend on replay fixtures recorded per build. The mock provider makes the + * baseline about the *host's* persistence, which is what is under test — and + * keeps the run tokenless, networkless and fixture-free. + */ + +import { mkdirSync, mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { timeout } from '../../../../../../base/common/async.js'; +import { join } from '../../../../../../base/common/path.js'; +import { LiveCompatAhpClient } from './agentHostLiveCompatClient.js'; +import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; +import { + createAgentHostCapabilityAdapter, + LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, + type IAgentHostCapabilityAdapter, +} from './agentHostLiveCompatCapabilities.js'; +import { startLiveCompatServer, stopLiveCompatServer, type ILiveCompatLaunchOptions, type ILiveCompatServerHandle } from './agentHostLiveCompatServer.js'; +import type { + AgentProviderCapabilities, + ILiveCompatInitializeResult, + ILiveCompatSessionList, + ILiveCompatSubscribeResult, +} from './agentHostLiveCompatProtocol.js'; + +/** Root channel URI. A constant of the protocol, stable across every build. */ +const ROOT_CHANNEL = 'ahp-root://'; +/** Provider the baseline drives; see the file header for why it is the mock. */ +const PROVIDER = 'mock'; +/** Title dispatched in phase 1 and expected back in phase 2. */ +const BASELINE_TITLE = 'Live Compat Baseline'; +const PER_CALL_TIMEOUT_MS = 30_000; + +/** + * A restored session is not necessarily describable the instant the host is + * accepting connections: the provider is re-registered and the catalogue + * re-read concurrently with the socket opening, and until that settles + * `subscribe` answers with a transient "could not describe … yet". Retrying is + * therefore part of the contract a client must implement, not a workaround — + * but the budget is bounded so a genuinely lost session still fails. + */ +const RESTORE_ATTEMPTS = 20; +const RESTORE_RETRY_DELAY_MS = 500; + +/** + * Time allowed for a rename's catalogue write to reach disk before the host is + * restarted. See the note at its use site: the host exposes no acknowledgment + * for this write and does not await it on shutdown, so a bounded wait is + * currently the only way to distinguish "lost on restart" from "restarted + * before it was ever written". Measured to complete in well under 100ms on + * every checkpoint in the matrix; the margin is for slower CI disks. + */ +const RENAME_SETTLE_MS = 1_000; + +/** Outcome of one step, in the order the scenario performed them. */ +export interface ILiveCompatStepResult { + readonly name: string; + readonly outcome: 'passed' | 'failed' | 'skipped'; + readonly durationMs: number; + /** Why a step was skipped, or how it failed. Absent when it passed. */ + readonly detail?: string; +} + +/** Machine-readable result of one build's baseline. */ +export interface ILiveCompatScenarioResult { + readonly scenario: string; + readonly build: string; + /** Provenance of the launched build (commit sha, or working-tree marker). */ + readonly buildDescription?: string; + readonly outcome: 'passed' | 'failed'; + readonly durationMs: number; + readonly protocolVersion?: string; + readonly steps: readonly ILiveCompatStepResult[]; + /** + * Directory retained for post-mortem: holds the home, the user-data + * directory (and therefore the host's own logs) and the workspace, for both + * phases. Never deleted — a baseline exists to be diagnosed when it fails, + * and its state is the diagnosis. + */ + readonly diagnosticsPath: string; + /** Present when the scenario failed. */ + readonly error?: string; +} + +export interface ILiveCompatScenarioOptions { + /** Root under which the per-build diagnostics directory is created. */ + readonly diagnosticsRoot?: string; + /** Extra environment for both launches, e.g. mock-provider seeding. */ + readonly env?: Readonly>; +} + +/** Records step outcomes and their durations in performance order. */ +class StepRecorder { + private readonly _steps: ILiveCompatStepResult[] = []; + + get steps(): readonly ILiveCompatStepResult[] { + return this._steps; + } + + async run(name: string, body: () => Promise): Promise { + const startedAt = Date.now(); + try { + const result = await body(); + this._steps.push({ name, outcome: 'passed', durationMs: Date.now() - startedAt }); + return result; + } catch (error) { + this._steps.push({ name, outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }); + throw error; + } + } + + skip(name: string, reason: string): void { + this._steps.push({ name, outcome: 'skipped', durationMs: 0, detail: reason }); + } +} + +/** + * Run the same-build restart baseline for one prepared build. + * + * Never throws for a scenario failure: a failed build is data the caller needs + * alongside the builds that passed, so the failure is reported in the returned + * result. Only a defect in the runner itself propagates. + */ +export async function runSameBuildRestartBaseline( + build: IPreparedAgentHostBuild, + options: ILiveCompatScenarioOptions = {}, +): Promise { + const startedAt = Date.now(); + const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-live-compat-${build.id}-`)); + const dirs = createPersistentDirectories(diagnosticsPath); + const recorder = new StepRecorder(); + let server: ILiveCompatServerHandle | undefined; + let client: LiveCompatAhpClient | undefined; + let protocolVersion: string | undefined; + + const launch: ILiveCompatLaunchOptions = { + serverEntry: build.serverEntry, + homeDir: dirs.homeDir, + userDataDir: dirs.userDataDir, + env: options.env, + }; + + try { + server = await recorder.run('launch', () => startLiveCompatServer(launch)); + client = await connect(server); + + const adapter = await recorder.run('initialize', async () => { + const initialize = await client!.call('initialize', { + channel: ROOT_CHANNEL, + protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], + clientId: `live-compat-${build.id}-seed`, + }, PER_CALL_TIMEOUT_MS); + protocolVersion = initialize.protocolVersion; + return createAgentHostCapabilityAdapter({ + protocolVersion: initialize.protocolVersion, + providerCapabilities: await readProviderCapabilities(client!), + }); + }); + + await recorder.run('list-empty', async () => { + const listed = await client!.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + assertEqual(listed.items?.length ?? 0, 0, 'a fresh profile must list no sessions'); + }); + + const sessionUri = await recorder.run('create-session', async () => { + const uri = `${PROVIDER}:/live-compat-${build.id}-${Date.now()}`; + await client!.call('createSession', { channel: uri, provider: PROVIDER }, PER_CALL_TIMEOUT_MS); + await client!.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); + return uri; + }); + + await renameStep(recorder, client, adapter, sessionUri); + await peerChatStep(recorder, adapter); + + // The restart is only meaningful once the first process has fully exited + // and released the profile it was holding. + await recorder.run('restart', async () => { + client!.close(); + client = undefined; + await stopLiveCompatServer(server); + server = undefined; + server = await startLiveCompatServer({ + ...launch, + // The mock provider keeps its session index in memory, so a + // restarted process must be told which sessions the *provider* + // side already knows about. This mirrors what a real provider + // recovers from its own on-disk state; the host's persistence — + // which is what is under test — is not seeded and must be + // reconstructed from the retained user-data directory alone. + env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: sessionUri }, + }); + client = await connect(server); + await client.call('initialize', { + channel: ROOT_CHANNEL, + protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], + clientId: `live-compat-${build.id}-verify`, + }, PER_CALL_TIMEOUT_MS); + }); + + await recorder.run('list-restored', async () => { + const listed = await client!.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + const restored = listed.items?.find(item => item.resource === sessionUri); + assertEqual(restored?.resource, sessionUri, 'the seeded session must be listed after a restart'); + if (adapter.supportsSessionRename) { + assertEqual(restored?.title, BASELINE_TITLE, 'the listed session must retain its custom title'); + } + }); + + await recorder.run('subscribe-restored', async () => { + const state = await subscribeWithRestoreRetry(client!, sessionUri); + if (adapter.supportsSessionRename) { + assertEqual(state.title, BASELINE_TITLE, 'the resubscribed session must retain its custom title'); + } + }); + + return result(build, recorder, diagnosticsPath, startedAt, protocolVersion, undefined); + } catch (error) { + return result(build, recorder, diagnosticsPath, startedAt, protocolVersion, messageOf(error)); + } finally { + client?.close(); + await stopLiveCompatServer(server).catch(() => undefined); + } +} + +async function renameStep( + recorder: StepRecorder, + client: LiveCompatAhpClient | undefined, + adapter: IAgentHostCapabilityAdapter, + sessionUri: string, +): Promise { + if (!adapter.supportsSessionRename) { + recorder.skip('rename-session', `negotiated protocol ${adapter.protocolVersion} predates client-dispatchable session/titleChanged`); + return; + } + await recorder.run('rename-session', async () => { + // `dispatchAction` is a write-ahead notification, so the readback is what + // confirms the host accepted and reduced it. + client!.notify('dispatchAction', { + channel: sessionUri, + clientSeq: 1, + action: { type: 'session/titleChanged', title: BASELINE_TITLE }, + }); + const state = await pollForTitle(client!, sessionUri, BASELINE_TITLE); + assertEqual(state.title, BASELINE_TITLE, 'the dispatched title must be observable before the restart'); + // Reducing the action and persisting it are distinct steps, and only the + // second is what a restart can recover. + // + // There is no AHP signal for the second one. `subscribe` and + // `listSessions` are both served from in-memory state, so they answer as + // soon as the reducer has run, and the catalogue write that actually + // makes the rename durable is queued fire-and-forget behind them. It is + // also not covered by the host's shutdown flush, which awaits the + // session-data and customization stores but not the catalogue store, so + // an immediate restart can genuinely lose a rename that every readable + // surface already reports as applied. + // + // A settle window is therefore the honest instrument here, and it is + // deliberately explicit rather than hidden inside a retry: the baseline + // is not asserting "renames are durable instantly", it is asserting + // "a rename that has been given time to persist survives a restart". + // Narrowing this window is a host-side change (an observable durability + // ack), not a scenario change. + await timeout(RENAME_SETTLE_MS); + }); +} + +/** + * Peer chats are recorded as an explicitly skipped step rather than omitted. + * + * The mock provider does not advertise `multipleChats`, so no build in the + * matrix can create one here — but that is a property of the *provider*, and + * stating it in the result keeps the baseline's coverage honest instead of + * silently narrower than it looks. + */ +async function peerChatStep(recorder: StepRecorder, adapter: IAgentHostCapabilityAdapter): Promise { + if (!adapter.supportsPeerChats(PROVIDER)) { + recorder.skip('peer-chat', `provider '${PROVIDER}' does not advertise multipleChats on this build`); + return; + } + // Reached only if the reference provider gains the capability; until then + // the baseline deliberately makes no peer-chat claim. + recorder.skip('peer-chat', 'peer-chat baseline coverage is owned by the cross-version matrices'); +} + +function createPersistentDirectories(root: string): { homeDir: string; userDataDir: string } { + const homeDir = join(root, 'home'); + const userDataDir = join(root, 'user-data'); + mkdirSync(homeDir, { recursive: true }); + mkdirSync(join(homeDir, '.codex'), { recursive: true }); + mkdirSync(userDataDir, { recursive: true }); + mkdirSync(join(root, 'workspace'), { recursive: true }); + return { homeDir, userDataDir }; +} + +async function connect(server: ILiveCompatServerHandle): Promise { + const client = new LiveCompatAhpClient(server.port); + await client.connect(); + return client; +} + +/** Read provider capabilities off the root snapshot, as any client would. */ +async function readProviderCapabilities(client: LiveCompatAhpClient): Promise> { + const root = await client.call('subscribe', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); + const capabilities = new Map(); + for (const agent of root.snapshot?.state?.agents ?? []) { + capabilities.set(agent.provider, agent.capabilities ?? {}); + } + return capabilities; +} + +async function pollForTitle(client: LiveCompatAhpClient, sessionUri: string, expected: string): Promise<{ title?: string }> { + let state: { title?: string } = {}; + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); + state = subscribed.snapshot?.state ?? {}; + if (state.title === expected) { + return state; + } + await timeout(RESTORE_RETRY_DELAY_MS); + } + return state; +} + +/** Subscribe to a restored session, tolerating the transient describe window. */ +async function subscribeWithRestoreRetry(client: LiveCompatAhpClient, sessionUri: string): Promise<{ title?: string }> { + let lastError: unknown; + for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { + try { + const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); + return subscribed.snapshot?.state ?? {}; + } catch (error) { + lastError = error; + await timeout(RESTORE_RETRY_DELAY_MS); + } + } + throw new Error(`could not resubscribe to ${sessionUri} within ${RESTORE_ATTEMPTS} attempts: ${messageOf(lastError)}`); +} + +function result( + build: IPreparedAgentHostBuild, + recorder: StepRecorder, + diagnosticsPath: string, + startedAt: number, + protocolVersion: string | undefined, + error: string | undefined, +): ILiveCompatScenarioResult { + return { + scenario: 'same-build-restart-baseline', + build: build.id, + buildDescription: build.description, + outcome: error === undefined ? 'passed' : 'failed', + durationMs: Date.now() - startedAt, + protocolVersion, + steps: recorder.steps, + diagnosticsPath, + ...(error === undefined ? {} : { error }), + }; +} + +function assertEqual(actual: T, expected: T, what: string): void { + if (actual !== expected) { + throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index 1f626bee574505..f752c369c3a6bf 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -805,7 +805,7 @@ export async function startServer(options?: { readonly quiet?: boolean; readonly * Start the agent host server with the Copilot SDK agent with either a real or mocked LLM. * The server is started with logging enabled so the CopilotAgent is registered. */ -export async function startRealServer(options: { readonly homeDir: string; readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string; readonly codexHomeDir?: string; readonly codexAgentEnabled?: boolean; readonly mockLlm?: boolean; readonly userDataDir?: string; readonly logLevel?: string; readonly env?: NodeJS.ProcessEnv; readonly capiReplay?: { readonly fixturePath: string; readonly mode?: CapiReplayMode; readonly workDir?: string; readonly real?: boolean; readonly allowPosixCommands?: boolean; readonly allowStaleRecordedRequest?: boolean }; readonly existingCapiReplay?: CapiReplayProxy; readonly mockScenarios?: readonly IMockScenario[] }): Promise { +export async function startRealServer(options: { readonly homeDir: string; readonly serverEntry?: string; readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string; readonly codexHomeDir?: string; readonly codexAgentEnabled?: boolean; readonly mockLlm?: boolean; readonly userDataDir?: string; readonly logLevel?: string; readonly env?: NodeJS.ProcessEnv; readonly capiReplay?: { readonly fixturePath: string; readonly mode?: CapiReplayMode; readonly workDir?: string; readonly real?: boolean; readonly allowPosixCommands?: boolean; readonly allowStaleRecordedRequest?: boolean }; readonly existingCapiReplay?: CapiReplayProxy; readonly mockScenarios?: readonly IMockScenario[] }): Promise { // `capiReplay` records/replays in front of the mock LLM server, so it implies // a mock upstream even when `mockLlm` was not explicitly requested — unless // `real` is set, in which case the proxy forwards to real CAPI/GitHub. @@ -842,7 +842,10 @@ export async function startRealServer(options: { readonly homeDir: string; reado // The agent host talks to the proxy (when replaying) or directly to the mock. const capiUrl = capiReplayProxy?.url ?? mockLlmServer?.url; return new Promise((resolve, reject) => { - const serverPath = fileURLToPath(new URL('../../node/agentHostServerMain.js', import.meta.url)); + // Cross-version (live compatibility) runs launch a *different* build's + // compiled server entry against the same persistent dirs; everything else + // about the launch is identical. + const serverPath = options.serverEntry ?? fileURLToPath(new URL('../../node/agentHostServerMain.js', import.meta.url)); const args = ['--port', '0', '--without-connection-token']; if (options.claudeSdkRoot) { args.push('--claude-sdk-root', options.claudeSdkRoot); 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 37dc64b62a170b..432e125e5412e2 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 @@ -125,10 +125,12 @@ retention when monitoring ends. ### Host session catalog The local Agent Host maintains a host-wide `sessions_v2` SQLite registry and -catalog. Each row contains current registry identity plus bounded list-visible -session and chat metadata. Per-session databases continue to own turns, drafts, -annotations, detailed changesets, and opaque provider backing required when a -session or chat is opened. +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. Per-session +databases continue to own turns, drafts, annotations, detailed changesets, and +opaque provider backing required when a session or chat is opened. Catalog persistence is legacy-first during the compatibility window: one per-session transaction updates downgrade-compatible metadata and a durable @@ -146,7 +148,7 @@ 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. Projection-versioned per-provider markers record successful +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. @@ -159,24 +161,23 @@ 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 complete v2 row and is guarded by the session +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. Runtime rollback selects legacy -read mode; no retained central generation is required. - -Each row also persists top-level eligibility. Chat-backing sessions therefore -remain hidden after restart without opening their per-session database. For -worktree sessions, both legacy and central projections derive the displayed -project from the persisted repository root rather than the worktree checkout. - -Session listing supports internal legacy, shadow, central-with-fallback, and -central-only modes. Shadow validation is non-blocking and reports aggregate -categories without session content. Central-with-fallback resolves each -registered session independently: verified current-version catalog rows avoid -provider metadata calls and per-session database opens, while missing, stale, -or malformed rows use the legacy path and schedule reconciliation. The -production default remains conservative until rollout explicitly selects a -central mode. +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 From e7baa3e7a78f05a86e4d171672777666b6be0886 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 26 Aug 2026 15:21:04 +0200 Subject: [PATCH 05/30] agentHost: remove live compatibility test harness Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 9 - scripts/test-agent-host-live-compat.ts | 816 ----------------- .../agentHost/test/node/e2e/README.md | 189 ---- .../node/e2e/harness/agentHostBuildPlan.ts | 197 ----- .../e2e/harness/agentHostLiveCompatBuilds.ts | 88 -- .../agentHostLiveCompatHarness.test.ts | 162 ---- .../harness/crossVersionAgentHostTarget.ts | 190 ---- .../agentHostLiveCompatCapabilities.test.ts | 76 -- .../agentHostLiveCompatCapabilities.ts | 115 --- .../liveCompat/agentHostLiveCompatClient.ts | 144 --- .../liveCompat/agentHostLiveCompatMatrix.ts | 130 --- .../liveCompat/agentHostLiveCompatProtocol.ts | 64 -- .../liveCompat/agentHostLiveCompatServer.ts | 151 ---- .../backwardCompatibilityMatrix.test.ts | 81 -- .../liveCompat/backwardCompatibilityMatrix.ts | 627 ------------- .../liveCompat/forwardMigrationMatrix.test.ts | 63 -- .../e2e/liveCompat/forwardMigrationMatrix.ts | 624 ------------- .../e2e/liveCompat/liveCompatRunner.test.ts | 186 ---- .../e2e/liveCompat/recoveryMatrix.test.ts | 161 ---- .../node/e2e/liveCompat/recoveryMatrix.ts | 828 ------------------ .../runBackwardCompatibilityMatrix.ts | 87 -- .../liveCompat/runForwardMigrationMatrix.ts | 128 --- .../node/e2e/liveCompat/runRecoveryMatrix.ts | 177 ---- .../liveCompat/sameBuildRestartBaseline.ts | 403 --------- .../test/node/serverIntegrationTestHelpers.ts | 7 +- 25 files changed, 2 insertions(+), 5701 deletions(-) delete mode 100644 scripts/test-agent-host-live-compat.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatBuilds.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatHarness.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/harness/crossVersionAgentHostTarget.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatClient.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatMatrix.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatProtocol.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatServer.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/liveCompatRunner.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.test.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/runBackwardCompatibilityMatrix.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/runForwardMigrationMatrix.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/runRecoveryMatrix.ts delete mode 100644 src/vs/platform/agentHost/test/node/e2e/liveCompat/sameBuildRestartBaseline.ts diff --git a/package.json b/package.json index 6e3b8941c9d4b1..add5332642dd9d 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,6 @@ "test-agent-host-e2e": "node scripts/test-agent-host-e2e.ts", "markdown-editor-package-json-check": "npm --prefix extensions/markdown-language-features run check-markdown-editor-package-json", "test-agent-host-e2e-coverage": "node scripts/agent-host-e2e-coverage.ts", - "agent-host-live-compat": "node scripts/test-agent-host-live-compat.ts", - "agent-host-live-compat-prepare": "node scripts/test-agent-host-live-compat.ts --prepare-all", - "agent-host-live-compat-check": "node scripts/test-agent-host-live-compat.ts --check", - "agent-host-live-compat-baselines": "node scripts/test-agent-host-live-compat.ts --run-baselines", - "agent-host-live-compat-forward": "node scripts/test-agent-host-live-compat.ts --run-forward", - "agent-host-live-compat-backward": "node scripts/test-agent-host-live-compat.ts --run-backward", - "agent-host-live-compat-recovery": "node scripts/test-agent-host-live-compat.ts --run-recovery", - "agent-host-live-compat-all": "node scripts/test-agent-host-live-compat.ts --run-all", - "agent-host-live-compat-pr": "node scripts/test-agent-host-live-compat.ts --run-all --pr", "check-cyclic-dependencies": "node build/lib/checkCyclicDependencies.ts out", "preinstall": "node build/npm/preinstall.ts", "postinstall": "node build/npm/postinstall.ts", diff --git a/scripts/test-agent-host-live-compat.ts b/scripts/test-agent-host-live-compat.ts deleted file mode 100644 index a8f09660f13dae..00000000000000 --- a/scripts/test-agent-host-live-compat.ts +++ /dev/null @@ -1,816 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Prepare the Agent Host builds that live-compatibility E2E scenarios run - * against. - * - * Historical checkpoints are materialized into **detached git worktrees** under - * a cache root outside the repository and compiled there. The repository the - * developer is working in is never checked out, reset, stashed or cleaned: the - * `current` checkpoint is simply built in place. - * - * Once builds are prepared it also *runs* the live-compat scenarios against - * them, since the two steps share the same checkpoint list and cache layout. - * Preparation and execution stay separate commands: preparing compiles whole - * source trees and is slow, while a scenario run is cheap and repeated. - * - * Usage: - * node scripts/test-agent-host-live-compat.ts --list - * node scripts/test-agent-host-live-compat.ts --prepare legacy [--prepare current] - * node scripts/test-agent-host-live-compat.ts --prepare-all [--force] - * node scripts/test-agent-host-live-compat.ts --check - * node scripts/test-agent-host-live-compat.ts --run-baselines [--build legacy] - * node scripts/test-agent-host-live-compat.ts --run-forward - * node scripts/test-agent-host-live-compat.ts --run-backward - * node scripts/test-agent-host-live-compat.ts --run-recovery - * node scripts/test-agent-host-live-compat.ts --run-all [--pr] [--output-dir ] - * - * Every `--run-*` command writes a stable JSON summary under - * `.build/agent-host-live-compat` (override with `--output-dir`) and exits - * nonzero if any scenario failed, including scenarios that failed only because - * their checkpoint was not prepared: an unresolved checkpoint is reported as a - * failure, never skipped. - * - * The cache layout and marker format are the contract shared with - * `src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts`; - * change both together. - */ - -const childProcess: typeof import('child_process') = require('child_process'); -const fs: typeof import('fs') = require('fs'); -const os: typeof import('os') = require('os'); -const path: typeof import('path') = require('path'); -const { spawnSync } = childProcess; -const { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } = fs; -const { tmpdir } = os; -const { dirname, join, resolve } = path; -const { pathToFileURL }: typeof import('url') = require('url'); - -const repoRoot = resolve(__dirname, '..'); - -/** Keep in sync with `AGENT_HOST_LIVE_COMPAT_RECIPE_VERSION`. */ -const RECIPE_VERSION = '1'; -/** - * Legacy marker location: inside the worktree, per `IAgentHostBuildPlan`. - * - * Still read so an already-prepared cache keeps working, but no longer written - * — see {@link markerPathFor} for why an in-worktree marker is a problem. - */ -const CACHE_MARKER_NAME = '.agent-host-live-compat-build.json'; -/** - * Files this script may itself leave in a worktree, and which therefore must - * not count as "local modifications" when deciding whether reuse is safe. - */ -const SCRIPT_OWNED_WORKTREE_FILES: readonly string[] = [CACHE_MARKER_NAME]; -/** Keep in sync with `AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH`. */ -const SERVER_ENTRY_RELATIVE_PATH = join('out', 'vs', 'platform', 'agentHost', 'node', 'agentHostServerMain.js'); - -interface IBuild { - readonly id: string; - readonly ref?: string; - readonly description: string; -} - -/** Keep in sync with `agentHostLiveCompatBuilds`. */ -const builds: readonly IBuild[] = [ - { id: 'legacy', ref: '97ed7b57c6d9becb4fe386c59157eda016050d6a', description: 'Oldest supported Agent Host build in the compatibility matrix.' }, - { id: 'predecessor', ref: '49f24d87cd32d2a696e469d2c61fb8d0cada4cc9', description: 'The build immediately preceding the in-flight changes.' }, - { id: 'intermediate', ref: '7453d67fdcde27faba527d69a535ddd51b8d1afa', description: 'Intermediate build used to exercise multi-hop upgrades.' }, - { id: 'current', description: 'The current working tree, built in place; never checked out or reset.' }, -]; - -function cacheRoot(): string { - return process.env['AGENT_HOST_LIVE_COMPAT_CACHE'] || join(tmpdir(), 'vscode-agent-host-live-compat'); -} - -function sourceRootFor(build: IBuild): string { - return build.ref === undefined ? repoRoot : join(cacheRoot(), 'builds', build.id); -} - -/** - * The matrices, in the order `--run-all` executes them. - * - * Order is cheapest-first and dependency-shaped: baselines prove each build can - * restart against its own profile at all, so a failure there explains every - * later cross-build failure and is worth seeing before spending time on them. - * - * They run **sequentially**, never in parallel. Each scenario forks real Agent - * Host processes from separately compiled trees that share this machine's temp - * space, ports and Electron caches; overlapping them would make a failure - * attributable to contention rather than to compatibility. - */ -const MATRICES = ['baselines', 'forward', 'backward', 'recovery'] as const; -type MatrixId = typeof MATRICES[number]; - -/** Default location for retained JSON evidence, relative to the repo root. */ -const DEFAULT_OUTPUT_DIR = join('.build', 'agent-host-live-compat'); - -/** Stable, per-matrix summary file names; CI collects these by name. */ -const SUMMARY_FILE_NAMES: Readonly> = { - baselines: 'baselines.json', - forward: 'forward-migration.json', - backward: 'backward-compatibility.json', - recovery: 'recovery.json', -}; - -interface IOptions { - readonly prepare: readonly string[]; - readonly force: boolean; - readonly list: boolean; - readonly check: boolean; - readonly matrices: readonly MatrixId[]; - readonly runBuilds: readonly string[] | undefined; - readonly jsonPath: string | undefined; - readonly outputDir: string; - readonly pr: boolean; -} - -async function main(): Promise { - const options = parseArguments(process.argv.slice(2)); - if (options.list) { - printStatus(); - return; - } - if (options.check) { - // Non-destructive by contract: it reports readiness and never prepares, - // compiles, checks out or deletes anything. - const missing = builds.filter(build => !isReady(build).ready); - printStatus(); - if (missing.length > 0) { - console.error(`\nNot ready: ${missing.map(build => build.id).join(', ')}. Run with --prepare-all.`); - process.exitCode = 1; - } - return; - } - for (const id of options.prepare) { - prepare(lookup(id), options.force); - } - if (options.matrices.length > 0) { - await runMatrices(options); - return; - } - printStatus(); -} - -function parseArguments(args: readonly string[]): IOptions { - const prepare: string[] = []; - const runBuilds: string[] = []; - const matrices: MatrixId[] = []; - let force = false; - let list = false; - let check = false; - let jsonPath: string | undefined; - let outputDir: string | undefined; - let pr = false; - const addMatrix = (id: MatrixId) => { - if (!matrices.includes(id)) { - matrices.push(id); - } - }; - for (let index = 0; index < args.length; index++) { - const argument = args[index]; - if (argument === '--prepare') { - const value = args[++index]; - if (!value) { - throw new Error('--prepare requires a build id'); - } - prepare.push(value); - } else if (argument.startsWith('--prepare=')) { - prepare.push(argument.slice('--prepare='.length)); - } else if (argument === '--prepare-all') { - prepare.push(...builds.map(build => build.id)); - } else if (argument === '--force') { - force = true; - } else if (argument === '--list') { - list = true; - } else if (argument === '--check') { - check = true; - } else if (argument === '--run-baselines') { - addMatrix('baselines'); - } else if (argument === '--run-forward') { - addMatrix('forward'); - } else if (argument === '--run-backward') { - addMatrix('backward'); - } else if (argument === '--run-recovery') { - addMatrix('recovery'); - } else if (argument === '--run-all') { - for (const id of MATRICES) { - addMatrix(id); - } - } else if (argument === '--pr') { - pr = true; - } else if (argument === '--build') { - const value = args[++index]; - if (!value) { - throw new Error('--build requires a build id'); - } - runBuilds.push(value); - } else if (argument.startsWith('--build=')) { - runBuilds.push(argument.slice('--build='.length)); - } else if (argument === '--json') { - const value = args[++index]; - if (!value) { - throw new Error('--json requires a path'); - } - jsonPath = value; - } else if (argument.startsWith('--json=')) { - jsonPath = argument.slice('--json='.length); - } else if (argument === '--output-dir') { - const value = args[++index]; - if (!value) { - throw new Error('--output-dir requires a path'); - } - outputDir = value; - } else if (argument.startsWith('--output-dir=')) { - outputDir = argument.slice('--output-dir='.length); - } else { - throw new Error(`Unknown argument '${argument}'`); - } - } - if (runBuilds.length > 0 && !matrices.includes('baselines')) { - throw new Error('--build only applies to --run-baselines'); - } - if (jsonPath !== undefined && matrices.length !== 1) { - throw new Error('--json applies to a single matrix; use --output-dir to place several summaries'); - } - if (pr && matrices.length === 0) { - throw new Error('--pr selects a faster subset of a run; combine it with --run-all or a --run-* command'); - } - if (prepare.length === 0 && !list && !check && matrices.length === 0) { - list = true; - } - return { - prepare, - force, - list, - check, - matrices, - runBuilds: runBuilds.length > 0 ? runBuilds : undefined, - jsonPath, - outputDir: outputDir ?? DEFAULT_OUTPUT_DIR, - pr, - }; -} - - -function lookup(id: string): IBuild { - const build = builds.find(candidate => candidate.id === id); - if (!build) { - throw new Error(`Unknown build '${id}'; known: ${builds.map(candidate => candidate.id).join(', ')}`); - } - return build; -} - -function isReady(build: IBuild): { ready: boolean; reason?: string } { - const sourceRoot = sourceRootFor(build); - if (!existsSync(join(sourceRoot, SERVER_ENTRY_RELATIVE_PATH))) { - return { ready: false, reason: 'not compiled' }; - } - if (build.ref === undefined) { - // The working tree is never cached: it changes under us by design. - return { ready: true }; - } - const cacheKey = tryCacheKeyFor(build); - if (cacheKey === undefined) { - return { ready: false, reason: unresolvedRefReason(build.ref) }; - } - if (readMarker(build)?.cacheKey !== cacheKey) { - return { ready: false, reason: 'stale build output' }; - } - return { ready: true }; -} - -/** - * Where this script records a build's cache key, outside the worktree. - * - * The marker is *also* written inside the worktree, because that in-worktree - * path is the contract `IAgentHostBuildPlan.cacheMarkerPath` reads when the - * matrices decide whether a build is launchable — see {@link writeMarker}. - * This copy exists so the CLI's own readiness check does not depend on a file - * living in a tree it may have to re-checkout. - */ -function markerPathFor(build: IBuild): string { - return join(cacheRoot(), 'markers', `${build.id}.json`); -} - -function readMarker(build: IBuild): { cacheKey?: string } | undefined { - // The legacy in-worktree location is still read so an already-prepared - // cache is not silently invalidated by this change; it is never written. - for (const candidate of [markerPathFor(build), join(sourceRootFor(build), CACHE_MARKER_NAME)]) { - try { - return JSON.parse(readFileSync(candidate, 'utf8')) as { cacheKey?: string }; - } catch { - // Try the next location. - } - } - return undefined; -} - -/** - * Record a completed build in both places that need to know about it. - * - * The in-worktree copy is not optional: `IAgentHostBuildPlan.cacheMarkerPath` - * points there, and it is what the matrices consult to decide a build is - * launchable rather than stale. Writing only the external copy makes every - * historical build report as "stale build output" at run time — which is - * exactly what a cold end-to-end run caught. - * - * It is an untracked file, so it would ordinarily make the worktree look dirty - * and block reuse for another checkpoint. That is handled by excluding this one - * known name in {@link SCRIPT_OWNED_WORKTREE_FILES}, rather than by loosening - * the dirty check, so a genuine local edit still stops reuse. - */ -function writeMarker(build: IBuild, cacheKey: string): void { - const contents = `${JSON.stringify({ cacheKey, builtAt: new Date().toISOString() }, undefined, '\t')}\n`; - const markerPath = markerPathFor(build); - mkdirSync(dirname(markerPath), { recursive: true }); - writeFileSync(markerPath, contents); - writeFileSync(join(sourceRootFor(build), CACHE_MARKER_NAME), contents); -} - -function tryCacheKeyFor(build: IBuild): string | undefined { - const commit = tryResolveCommit(build.ref!); - return commit === undefined ? undefined : `commit:${commit}|recipe:${RECIPE_VERSION}`; -} - -/** - * Resolve a checkpoint ref to a commit, or `undefined` when it is not present. - * - * Non-throwing by design. A checkpoint can legitimately be absent — a shallow - * clone, a fork, or a checkpoint that only ever existed on a feature branch — - * and in every one of those cases the useful outcome is the runner's own - * "not ready, here is what to do" result, not a raw `git rev-parse` stack from - * deep inside a status listing. - */ -function tryResolveCommit(ref: string): string | undefined { - const result = spawnSync('git', ['rev-parse', `${ref}^{commit}`], { - cwd: repoRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - const commit = (result.stdout ?? '').trim(); - return /^[0-9a-f]{40}$/.test(commit) ? commit : undefined; -} - -/** Resolve a ref for a command that genuinely cannot proceed without it. */ -function resolveCommit(ref: string): string { - const commit = tryResolveCommit(ref); - if (commit === undefined) { - throw new Error(unresolvedRefMessage(ref)); - } - return commit; -} - -function unresolvedRefReason(ref: string): string { - return `checkpoint ${ref.slice(0, 10)} is not present in this repository`; -} - -/** - * Explain an absent checkpoint, including the case this suite actually hits. - * - * Some checkpoints are pinned to commits that live only on a feature branch. - * Those are unreachable from a shallow clone, from a fork, and from the default - * branch until that work lands — so "fetch it" is only half the advice, and - * re-pinning is the other half. - */ -function unresolvedRefMessage(ref: string): string { - return [ - `[agent-host-live-compat] checkpoint '${ref}' could not be resolved in ${repoRoot}.`, - ` Fetch it: git fetch origin ${ref}`, - ' If it was never on a shared branch (a feature-branch-only checkpoint), re-pin it', - ' to a commit reachable from the default branch in agentHostLiveCompatBuilds.ts.', - ].join('\n'); -} - -function prepare(build: IBuild, force: boolean): void { - const sourceRoot = sourceRootFor(build); - const state = isReady(build); - if (state.ready && !force && build.ref !== undefined) { - console.log(`[live-compat] ${build.id}: up to date (${sourceRoot})`); - return; - } - - if (build.ref === undefined) { - // The working tree is never *cached* — it changes under us by design — - // but it can still be already built, and recompiling it is the single - // slowest thing this command does. `--force` remains the way to insist. - if (state.ready && !force) { - console.log(`[live-compat] ${build.id}: already compiled (${repoRoot}); pass --force to rebuild`); - return; - } - console.log(`[live-compat] ${build.id}: building the current working tree in place (${repoRoot})`); - compile(repoRoot); - console.log(`[live-compat] ${build.id}: ready`); - return; - } - - const commit = resolveCommit(build.ref); - materializeWorktree(build, commit, sourceRoot); - installDependencies(build, sourceRoot); - compile(sourceRoot); - writeMarker(build, `commit:${commit}|recipe:${RECIPE_VERSION}`); - console.log(`[live-compat] ${build.id}: ready at ${sourceRoot} (${commit})`); -} - -function materializeWorktree(build: IBuild, commit: string, sourceRoot: string): void { - mkdirSync(join(cacheRoot(), 'builds'), { recursive: true }); - if (existsSync(join(sourceRoot, '.git'))) { - // A cached worktree restored onto a fresh machine (as CI does) carries a - // `.git` file pointing at administrative data that lives in the *main* - // repository and was never part of the archive. Detect that here rather - // than letting the first git command fail with a link-resolution error - // that reads like a corrupt checkout. - const detached = isDetachedWorktree(sourceRoot); - if (!detached) { - console.log(`[live-compat] ${build.id}: cached worktree at ${sourceRoot} is no longer linked to this repository; re-registering it`); - rmSync(sourceRoot, { recursive: true, force: true }); - run('git', ['worktree', 'prune'], repoRoot); - } else { - const head = (run('git', ['rev-parse', 'HEAD'], sourceRoot, { capture: true }) ?? '').trim(); - if (head === commit) { - return; - } - // Reuse the worktree for a different checkpoint only when it is clean: - // a dirty cached worktree may hold work someone put there on purpose. - // Files this script owns are not "someone's work": the legacy - // in-worktree marker and the install sentinel are excluded by name, - // and nothing else is, so a real edit still stops the reuse. - const status = run('git', ['status', '--porcelain'], sourceRoot, { capture: true }) ?? ''; - const foreign = status.split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - .filter(line => !SCRIPT_OWNED_WORKTREE_FILES.some(name => line.endsWith(name))); - if (foreign.length > 0) { - throw new Error(`Cached worktree ${sourceRoot} has local modifications; inspect and remove it manually (git worktree remove ${sourceRoot}).`); - } - run('git', ['checkout', '--detach', commit], sourceRoot); - return; - } - } - if (existsSync(sourceRoot)) { - throw new Error(`${sourceRoot} exists but is not a git worktree; remove it manually before preparing '${build.id}'.`); - } - console.log(`[live-compat] ${build.id}: creating worktree at ${sourceRoot} (${commit})`); - run('git', ['worktree', 'add', '--detach', sourceRoot, commit], repoRoot); -} - -/** True when `sourceRoot` is a git worktree this repository can still drive. */ -function isDetachedWorktree(sourceRoot: string): boolean { - const result = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: sourceRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - return result.status === 0; -} - -/** - * Native modules the Agent Host loads at run time. - * - * `--ignore-scripts` skips the install hooks that compile these, which is most - * of the saving — but the host does not merely reference them, it fails to - * start without them. A cold end-to-end run caught exactly that: every - * historical build exited 1 on `Cannot find module '../build/Debug/vscode_fs.node'`. - * So they are rebuilt explicitly, which is bounded work (~19 s) rather than the - * repository-wide postinstall. - * - * `sqlite3` backs the session database — the very thing these scenarios migrate - * — and `fs-copyfile` is reached during startup, so neither is optional. - */ -const AGENT_HOST_NATIVE_MODULES: readonly string[] = [ - '@vscode/fs-copyfile', - '@vscode/sqlite3', - '@vscode/spdlog', - '@parcel/watcher', - 'node-pty', -]; - -/** - * Install only what a checkpoint needs to transpile and run an Agent Host. - * - * A plain `npm install` here is enormously more than that. It runs the - * repository-wide `postinstall`, which installs every built-in extension and - * the remote tree: measured on a checkpoint, that is **7.6 GB** and several - * minutes, of which the Agent Host uses none. `--ignore-scripts` skips exactly - * that step, leaving the root and `build/` dependency sets — which is what - * `transpile-client` and the compiled server actually load — at **2.9 GB**. - * - * The sentinel is the other half. `node_modules` exists from the moment npm - * starts writing into it, so treating its mere presence as "installed" silently - * reuses a half-installed tree left by an interrupted or failed run, and the - * failure resurfaces later as a confusing missing-module error during compile. - * The sentinel is written only after every install has exited zero. - */ -function installDependencies(build: IBuild, sourceRoot: string): void { - const sentinel = join(cacheRoot(), 'markers', `${build.id}.install.json`); - if (existsSync(sentinel)) { - return; - } - const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - const args = ['install', '--ignore-scripts', '--no-audit', '--no-fund']; - console.log(`[live-compat] ${build.id}: installing dependencies in ${sourceRoot}`); - run(npm, args, sourceRoot); - // `transpile-client` runs out of `build/`, whose dependencies the root - // install does not provide and whose postinstall step we just skipped. - console.log(`[live-compat] ${build.id}: installing build dependencies`); - run(npm, args, join(sourceRoot, 'build')); - console.log(`[live-compat] ${build.id}: rebuilding native modules`); - run(npm, ['rebuild', ...AGENT_HOST_NATIVE_MODULES], sourceRoot); - mkdirSync(dirname(sentinel), { recursive: true }); - writeFileSync(sentinel, `${JSON.stringify({ installedAt: new Date().toISOString(), args, nativeModules: AGENT_HOST_NATIVE_MODULES }, undefined, '\t')}\n`); -} - -function compile(sourceRoot: string): void { - console.log(`[live-compat] compiling ${sourceRoot}`); - run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'transpile-client'], sourceRoot); - const entry = join(sourceRoot, SERVER_ENTRY_RELATIVE_PATH); - if (!existsSync(entry)) { - throw new Error(`Compilation completed but ${entry} is missing; the build recipe may not apply to this checkpoint.`); - } -} - -function run(command: string, args: readonly string[], cwd: string, options?: { capture?: boolean }): string | undefined { - const result = spawnSync(command, args, { - cwd, - env: process.env, - encoding: 'utf8', - stdio: options?.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', - }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - const reason = result.signal ? `signal ${result.signal}` : `code ${result.status}`; - const details = options?.capture ? `\n${(result.stderr ?? '').trim()}` : ''; - throw new Error(`${command} ${args.join(' ')} (in ${cwd}) exited with ${reason}${details}`); - } - return options?.capture ? result.stdout : undefined; -} - -/** Compiled location of the scenario runners, produced by `npm run transpile-client`. */ -const OUT_LIVE_COMPAT_DIR = join('out', 'vs', 'platform', 'agentHost', 'test', 'node', 'e2e', 'liveCompat'); - -interface IStepResult { - readonly name: string; - readonly outcome: string; - readonly durationMs: number; - readonly detail?: string; -} - -/** - * The shape every matrix summary shares. - * - * Deliberately structural rather than a union of the four concrete summary - * types: this script only ever needs the aggregate outcome and a per-entry - * label, and the matrices remain free to add fields (recovery's classification - * tally, backward's two protocol versions) that flow into the JSON untouched. - */ -interface IMatrixSummary { - readonly suite: string; - readonly outcome: string; - readonly durationMs: number; - readonly results: readonly IScenarioResult[]; -} - -interface IScenarioResult { - readonly scenario?: string; - readonly build?: string; - readonly currentBuild?: string; - readonly olderBuild?: string; - readonly secondBuild?: string; - readonly outcome: string; - readonly durationMs: number; - readonly protocolVersion?: string; - readonly diagnosticsPath: string; - readonly error?: string; - readonly steps: readonly IStepResult[]; -} - -interface IMatrixDefinition { - readonly id: MatrixId; - readonly title: string; - /** Module under `out/` exporting the entry point, without extension. */ - readonly module: string; - readonly run: (module: Record, options: IMatrixRunOptions) => Promise; -} - -interface IMatrixRunOptions { - readonly repoRoot: string; - readonly resolveCommit: (ref: string) => string | undefined; - readonly cacheRoot: string; - readonly diagnosticsRoot: string; - /** Builds to exercise, when the matrix takes an explicit list. */ - readonly buildIds: readonly string[] | undefined; - /** True for the reduced subset a pull request runs. */ - readonly pr: boolean; -} - -/** - * How each matrix is invoked, and what `--pr` trims from it. - * - * The PR subset is chosen to keep the *shape* of every claim while cutting - * repetition: each matrix still runs, but against the nearest checkpoint only - * (`predecessor`), because a break introduced by an in-flight change shows up - * against its immediate predecessor first. The full three-checkpoint sweep — - * which is what actually pins "oldest supported" — belongs to the scheduled - * run, where its cost is paid once a day rather than once a push. - */ -const MATRIX_DEFINITIONS: readonly IMatrixDefinition[] = [ - { - id: 'baselines', - title: 'same-build restart baselines', - module: 'agentHostLiveCompatMatrix', - run: (module, options) => { - const run = module['runSameBuildRestartBaselines'] as (ids: readonly string[], o: object) => Promise; - const ids = options.buildIds ?? (options.pr ? ['predecessor', 'current'] : builds.map(build => build.id)); - return run(ids, matrixContext(options)); - }, - }, - { - id: 'forward', - title: 'forward migrations', - module: 'runForwardMigrationMatrix', - run: (module, options) => { - const run = module['runForwardMigrations'] as (o: object) => Promise; - return run({ - ...matrixContext(options), - ...(options.pr ? { sources: ['predecessor'], includeMultiSession: true } : {}), - }); - }, - }, - { - id: 'backward', - title: 'backward-compatibility round trips', - module: 'runBackwardCompatibilityMatrix', - run: (module, options) => { - const run = module['runBackwardCompatibilityMatrixForBuilds'] as (ids: readonly string[], o: object) => Promise; - const olderBuilds = module['BACKWARD_COMPAT_OLDER_BUILDS'] as readonly string[]; - return run(options.pr ? ['predecessor'] : olderBuilds, matrixContext(options)); - }, - }, - { - id: 'recovery', - title: 'process recovery', - module: 'runRecoveryMatrix', - run: (module, options) => { - const run = module['runRecoveryMatrix'] as (ids: readonly string[], o: object) => Promise; - const ids = options.pr ? ['current'] : ['current', 'predecessor']; - return run(ids, matrixContext(options)); - }, - }, -]; - -function matrixContext(options: IMatrixRunOptions): object { - return { - repoRoot: options.repoRoot, - resolveCommit: options.resolveCommit, - cacheRoot: options.cacheRoot, - diagnosticsRoot: options.diagnosticsRoot, - }; -} - -/** - * Run the requested matrices in order and retain a JSON summary for each. - * - * Two properties are load-bearing and are the reason this is not a shell loop - * over four commands: - * - * - **Sequential.** A single `await` chain, with no concurrency anywhere, so - * two compiled Agent Host trees never contend for temp space or ports. - * - **Nothing is silently dropped.** A matrix that throws is recorded as a - * failed summary and the remaining matrices still run, so one broken matrix - * cannot hide the state of the others; the process still exits nonzero. - */ -async function runMatrices(options: IOptions): Promise { - const outputDir = resolve(repoRoot, options.outputDir); - mkdirSync(outputDir, { recursive: true }); - const diagnosticsRoot = join(outputDir, 'diagnostics'); - mkdirSync(diagnosticsRoot, { recursive: true }); - for (const id of options.runBuilds ?? []) { - lookup(id); - } - - const startedAt = Date.now(); - const written: { id: MatrixId; outcome: string; durationMs: number; summaryPath: string }[] = []; - for (const id of options.matrices) { - const definition = MATRIX_DEFINITIONS.find(candidate => candidate.id === id)!; - console.log(`\n[live-compat] running ${definition.title}${options.pr ? ' (pr subset)' : ''}`); - const summary = await runMatrix(definition, { - repoRoot, - // The non-throwing resolver: an absent checkpoint becomes that - // build's own "not ready" row, carrying the prepare-or-re-pin - // advice, instead of aborting the whole matrix with a git error. - resolveCommit: ref => tryResolveCommit(ref), - cacheRoot: cacheRoot(), - diagnosticsRoot, - buildIds: options.runBuilds, - pr: options.pr, - }); - printSummary(definition, summary); - const summaryPath = options.jsonPath ? resolve(repoRoot, options.jsonPath) : join(outputDir, SUMMARY_FILE_NAMES[id]); - mkdirSync(dirname(summaryPath), { recursive: true }); - writeFileSync(summaryPath, `${JSON.stringify(summary, undefined, '\t')}\n`); - console.log(` summary written to ${summaryPath}`); - written.push({ id, outcome: summary.outcome, durationMs: summary.durationMs, summaryPath }); - } - - const outcome = written.every(entry => entry.outcome === 'passed') ? 'passed' : 'failed'; - if (options.matrices.length > 1) { - const runPath = join(outputDir, 'run.json'); - writeFileSync(runPath, `${JSON.stringify({ - suite: 'agent-host-live-compat', - subset: options.pr ? 'pr' : 'full', - startedAt: new Date(startedAt).toISOString(), - durationMs: Date.now() - startedAt, - outcome, - matrices: written, - }, undefined, '\t')}\n`); - console.log('\nAgent Host live-compat — run summary'); - for (const entry of written) { - console.log(` ${entry.id.padEnd(10)} ${entry.outcome.toUpperCase().padEnd(6)} ${formatDuration(entry.durationMs)}`); - } - console.log(` overall: ${outcome.toUpperCase()} in ${formatDuration(Date.now() - startedAt)}`); - console.log(` run summary written to ${runPath}`); - } - if (outcome !== 'passed') { - process.exitCode = 1; - } -} - -/** - * Load a matrix from `out/` and run it, turning a throw into a failed summary. - * - * A missing module means the working tree was never transpiled, which is worth - * saying plainly rather than surfacing as a module-resolution stack. The - * scenario modules are ESM under `out/`, so they are reached with a dynamic - * import from this CommonJS script. - */ -async function runMatrix(definition: IMatrixDefinition, options: IMatrixRunOptions): Promise { - const startedAt = Date.now(); - const modulePath = join(repoRoot, OUT_LIVE_COMPAT_DIR, `${definition.module}.js`); - try { - if (!existsSync(modulePath)) { - throw new Error(`Missing ${modulePath}. Run 'npm run transpile-client' first.`); - } - const module = await import(pathToFileURL(modulePath).href) as Record; - return await definition.run(module, options); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { - suite: `agent-host-live-compat/${definition.id}`, - outcome: 'failed', - durationMs: Date.now() - startedAt, - results: [{ - scenario: definition.id, - outcome: 'failed', - durationMs: Date.now() - startedAt, - diagnosticsPath: '', - error: detail, - steps: [{ name: 'load-matrix', outcome: 'failed', durationMs: Date.now() - startedAt, detail }], - }], - }; - } -} - -function printSummary(definition: IMatrixDefinition, summary: IMatrixSummary): void { - console.log(`\nAgent Host live-compat — ${definition.title}`); - for (const result of summary.results) { - console.log(` ${labelOf(result).padEnd(34)} ${result.outcome.toUpperCase().padEnd(6)} ${formatDuration(result.durationMs)}${result.protocolVersion ? ` protocol=${result.protocolVersion}` : ''}`); - for (const step of result.steps) { - console.log(` ${step.outcome.padEnd(7)} ${step.name}${step.detail ? ` — ${step.detail}` : ''}`); - } - if (result.diagnosticsPath) { - console.log(` diagnostics: ${result.diagnosticsPath}`); - } - } - console.log(` ${definition.id}: ${summary.outcome.toUpperCase()} in ${formatDuration(summary.durationMs)}`); -} - -/** Name a scenario entry across the four differently-shaped result types. */ -function labelOf(result: IScenarioResult): string { - const build = result.currentBuild && result.olderBuild - ? `${result.currentBuild}->${result.olderBuild}` - : result.secondBuild - ? `${result.build}->${result.secondBuild}` - : result.build ?? ''; - return result.scenario ? `${build} ${result.scenario}`.trim() : build; -} - -function formatDuration(durationMs: number): string { - return durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`; -} - - -function printStatus(): void { - console.log(`Agent Host live-compat builds (cache root: ${cacheRoot()})`); - for (const build of builds) { - const state = isReady(build); - const status = state.ready ? 'ready' : `NOT READY (${state.reason})`; - console.log(` ${build.id.padEnd(13)} ${build.ref ?? 'working tree'} ${status}`); - console.log(` ${''.padEnd(13)} ${sourceRootFor(build)}`); - } -} - -main().catch(error => { - console.error(`[live-compat] ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; -}); diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index ae201773a42ce4..0ced40c4bc66bd 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -520,195 +520,6 @@ Sysroot/asset download `429: Too Many Requests`, network resets, etc. are infras --- -## Live compatibility - -The suites above run one build against itself. The **live-compatibility** suite -(`liveCompat/`) runs *different builds against the same profile*, which is the -only way to ask whether a change to persisted state can be shipped: whether an -old profile opens in the new build, whether a new profile still opens in an old -one, and whether either survives an unclean death. - -### What "black box" means here — and what it does not - -Every build is driven **only over AHP**. The suite never imports host internals -and never inspects the database or any other persisted file directly: if a -migration claim cannot be observed through the protocol, it is not asserted. -That is the property the compatibility claims rest on, since a client speaking -AHP is exactly what a shipped build has to satisfy. - -It does **not** mean the host is launched the way a user launches it. Each build -is started with `--enable-mock-agent` and driven through the scripted mock -provider, so **no model is ever contacted** and a run is deterministic and -tokenless. That is a test-only flag, and it is a deliberate trade: these -scenarios are about *persisted state surviving a version change*, which the -choice of provider does not affect, and pinning them to a real provider would -make every compatibility result depend on fixtures that drift for unrelated -reasons. - -So this suite is not provider-parity evidence. Whether a provider behaves -correctly is what the `providers/` E2E suites answer, against recorded CAPI -fixtures. This suite answers whether a profile written by one build is usable by -another, and it is the only suite that answers that. - -### Builds - -Four checkpoints, listed by `npm run agent-host-live-compat`: - -| Build | Source | -|---|---| -| `legacy` | oldest supported checkpoint | -| `predecessor` | the build immediately preceding the in-flight change | -| `intermediate` | an intermediate checkpoint, for multi-hop upgrades | -| `current` | your working tree | - -The three historical ones are materialized as **detached git worktrees** under a -cache root outside the repository (`$TMPDIR/vscode-agent-host-live-compat`, or -`AGENT_HOST_LIVE_COMPAT_CACHE`) and compiled there. Your repository is never -checked out, reset, stashed or cleaned; `current` is simply built in place. - -```sh -# Prepare every build. Does real work the first time; a no-op afterwards. -npm run agent-host-live-compat-prepare - -# Report readiness. Non-destructive: it never prepares, compiles or deletes. -npm run agent-host-live-compat-check -``` - -**Prerequisites.** Preparation needs the checkpoint commits present locally -(they are resolved in *this* repository, so a shallow clone will not have them) -and network access for two `npm install` runs per checkpoint. A checkpoint that -cannot be resolved is reported as not-ready with both fixes — fetch it, or -re-pin it — and never silently skipped. - -**Cost.** Measured on macOS, cold, for all three historical checkpoints: -**~1 m 36 s** and **2.9 GB each (8.8 GB total)**. Re-running preparation once -ready is a no-op (~0.07 s). - -That footprint is deliberate, and its shape is worth knowing before trying to -shrink it further. Preparation installs with `--ignore-scripts`, skipping the -repository-wide `postinstall` that would otherwise install every built-in -extension and the remote tree — **7.6 GB** per checkpoint, none of which the -Agent Host uses. It then rebuilds, explicitly, only the native modules the host -actually loads at run time (`@vscode/sqlite3`, which backs the very database -these scenarios migrate, `@vscode/fs-copyfile`, `@vscode/spdlog`, -`@parcel/watcher`, `node-pty`). - -That rebuild is not optional: skipping it produces builds that transpile -cleanly and then exit 1 on startup with `Cannot find module -'../build/Debug/vscode_fs.node'`. Preparation is therefore validated by running -the full matrix from a cold cache, not by checking that the entry point exists. - -Preparation is cached on the checkpoint commit plus a build-recipe version, -recorded in a marker under the cache root (**not** inside the worktree, so the -worktree stays clean and the dirty check keeps protecting real local edits). -A separate sentinel records that dependency installation *succeeded*, so an -interrupted install is redone rather than half-reused. Change the recipe and -stale output is rebuilt rather than silently reused; `--force` rebuilds -regardless, and also forces a rebuild of `current`, which is otherwise left -alone when it is already compiled. - -### Running the matrices - -```sh -npm run agent-host-live-compat-baselines # each build restarts against its own profile -npm run agent-host-live-compat-forward # legacy/predecessor/intermediate ▸ current -npm run agent-host-live-compat-backward # current ▸ older ▸ current round trips -npm run agent-host-live-compat-recovery # SIGKILL and restart - -npm run agent-host-live-compat-all # all four, in that order -npm run agent-host-live-compat-pr # the reduced subset CI runs on a PR -``` - -Three properties of a run are worth knowing, because they are what the aggregate -command exists to guarantee: - -- **Matrices run sequentially.** Each scenario forks real Agent Host processes - from separately compiled trees that share this machine's temp space and ports. - Overlapping them would make a failure attributable to contention rather than - to compatibility. -- **An unresolved checkpoint is a failure, never a skip.** A build that was - never prepared is reported as a failed row carrying the exact `--prepare` - command to run, and the process exits nonzero. A run that covered two of three - upgrades can never be mistaken for one that covered three. -- **Evidence is retained.** Every matrix writes a JSON summary under - `.build/agent-host-live-compat` (`--output-dir` to relocate), and a multi-matrix - run adds `run.json` aggregating them. Each scenario also keeps its diagnostics - directory — the home, the user-data directory (and so the host's own logs) and - the workspace, for every phase. These are never deleted: a compatibility - failure exists to be diagnosed, and that state is the diagnosis. - -The full sweep takes roughly a minute against prepared builds. - -### CI — not yet wired up, and why - -**There is no workflow for this suite yet.** It is run manually with the -commands above. Wiring it into CI is deliberately deferred rather than -forgotten, because a prerequisite is not met today. - -Two of the three historical checkpoints (`49f24d8`, `7453d67`) exist only on the -feature branch that introduced this suite. They are unreachable from the default -branch, from a fork, and from the shallow clones CI jobs use — so a scheduled or -fork-triggered job could not resolve them, and would either fail for a reason -that has nothing to do with compatibility or, worse, appear to pass while -silently covering less than it claims. The runner refuses to do the latter: an -unresolvable checkpoint is reported as a failed row that names both fixes -(fetch, or re-pin), never skipped. - -**Prerequisites, in order:** - -1. This work lands on the default branch. -2. Re-pin `legacy`, `predecessor` and `intermediate` in - `harness/agentHostLiveCompatBuilds.ts` to commits reachable from the default - branch. (`legacy`, `97ed7b5`, already is; the other two are not.) -3. Confirm a cold preparation on a clean CI runner — the numbers below were - measured locally on macOS, and the install step is the part most likely to - differ. -4. Then add the workflow. - -**Intended shape**, once those hold: the reduced subset (`--pr`) on pull -requests, and the full sweep on a schedule and on demand. The subset keeps the -*shape* of every claim — all four matrices still run — but only against -`predecessor`, the checkpoint an in-flight regression shows up against first; -pinning "oldest supported" is what the scheduled run is for. Summaries and -diagnostics should be uploaded as an artifact on success and failure alike. - -Two details such a job must get right, both learned the hard way: - -- Checkpoints are resolved as commits **in this repository**, so a shallow - checkout cannot see them; the job needs full history (`fetch-depth: 0`). -- The path filter should cover `src/vs/platform/agentHost/**`, - `scripts/test-agent-host-live-compat.ts`, `package.json` (the commands live - there, so a change to them changes what CI runs) and the workflow itself. - -Caching the prepared worktrees is an obvious further win but is **unproven** — -a restored worktree's git metadata lives in the main repository and is not part -of the archive. The runner detects and re-registers that case, but no cache -round trip has actually been exercised on a runner, so it should be measured -before being relied on. - -### What recovery does *not* cover - -The recovery matrix asserts **convergence** after an unclean kill — the session -is present exactly once and is describable — rather than "the last write -survived". The host advertises no durability acknowledgment and the catalogue -write is queued fire-and-forget, so a rename is readable long before it is -durable; asserting that it survives would encode a guarantee the host does not -make and would flake as a function of disk speed. Which shape was observed is -reported in `classificationCounts`, so the durability gap stays visible as data. - -Two boundaries are **deliberately out of black-box reach** and are routed to -scoped integration tests rather than faked with a plausible-looking E2E: - -| Boundary | Why it is integration-only | -|---|---| -| `torn-write-corruption` | A black-box AHP client cannot truncate host-owned files; doing so would violate the externality principle. | -| `pending-receipt-at-kill` | The write queue is internal and no durability acknowledgment is advertised, so the boundary cannot be observed or targeted from outside. | - -Both ship inside every recovery summary as `boundaries` and -`integrationProposals`, described precisely enough to be implemented without -re-deriving the analysis. A run's most misreadable property is its *scope*, so -what was deliberately not covered travels in the same artifact as what passed. - ## Relationship to the protocol suite `../protocol/` is **frozen**. Do not add tests there; add them here. diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts deleted file mode 100644 index 1c9d6c2183ee5b..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostBuildPlan.ts +++ /dev/null @@ -1,197 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Pure planning layer for cross-version ("live compatibility") Agent Host runs. - * - * A live-compat scenario drives one preserved user-data directory through - * several *builds* of the Agent Host: an old release, an intermediate one, and - * the build currently under development. This module owns the decisions — - * where a build lives, how it is identified, when it may be reused — without - * performing any filesystem or git work, so the rules can be unit tested - * without checking out or compiling anything. - * - * The externality principle still holds: nothing here knows anything about the - * agent host beyond the path of its server entry point. - */ - -import { join } from '../../../../../../base/common/path.js'; - -/** How the sources for a build are obtained. */ -export const enum AgentHostBuildSourceKind { - /** - * An immutable git ref (commit sha, tag). Materialized into a detached - * worktree under the cache root and built there, never in the repository - * the test runs from. - */ - Ref = 'ref', - /** - * The developer's current (possibly dirty) working tree. Used as-is: never - * checked out, reset, stashed, or otherwise mutated. - */ - WorkingTree = 'workingTree', -} - -/** A named build a live-compat scenario can run a phase against. */ -export interface IAgentHostBuildDescriptor { - /** Stable id used in scenario code, reporting and cache paths, e.g. `legacy`. */ - readonly id: string; - readonly source: AgentHostBuildSourceKind; - /** Immutable git ref; required for {@link AgentHostBuildSourceKind.Ref}, forbidden otherwise. */ - readonly ref?: string; - /** Human readable note surfaced in diagnostics. */ - readonly description?: string; -} - -export interface IAgentHostBuildPlanContext { - /** Absolute path of the repository the test runs from. Never mutated for ref builds. */ - readonly repoRoot: string; - /** Absolute path under which historical worktrees and their outputs are cached. */ - readonly cacheRoot: string; - /** - * The resolved commit sha for a {@link AgentHostBuildSourceKind.Ref} build. - * Planning is pure, so the caller resolves the ref and passes the result in. - */ - readonly resolvedCommit?: string; - /** - * Bumped whenever the build recipe changes in a way that invalidates - * previously cached outputs. - */ - readonly recipeVersion: string; -} - -/** Where a build lives and how to tell whether it is already usable. */ -export interface IAgentHostBuildPlan { - readonly id: string; - readonly source: AgentHostBuildSourceKind; - readonly ref?: string; - readonly resolvedCommit?: string; - readonly description?: string; - /** Root of the sources for this build (a cached worktree, or the repo itself). */ - readonly sourceRoot: string; - /** Absolute path of the compiled agent host server entry to launch. */ - readonly serverEntry: string; - /** - * Identity of the built output. Equal keys mean the cached build is still - * valid; `undefined` for the working tree, which is never cached because it - * changes under us by design. - */ - readonly cacheKey: string | undefined; - /** File recording {@link cacheKey} for a completed build; `undefined` when uncacheable. */ - readonly cacheMarkerPath: string | undefined; - /** Whether a git worktree must be materialized before building. */ - readonly requiresWorktree: boolean; -} - -/** Relative path of the compiled agent host server entry within a build output root. */ -export const AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH = join('out', 'vs', 'platform', 'agentHost', 'node', 'agentHostServerMain.js'); - -const BUILD_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; -const COMMIT_PATTERN = /^[0-9a-f]{40}$/; - -/** - * Validate a descriptor and resolve it to concrete paths and a cache identity. - * - * @throws when the descriptor is internally inconsistent, which is always a - * scenario authoring bug rather than an environment problem. - */ -export function planAgentHostBuild(descriptor: IAgentHostBuildDescriptor, context: IAgentHostBuildPlanContext): IAgentHostBuildPlan { - if (!BUILD_ID_PATTERN.test(descriptor.id)) { - throw new Error(`[agent-host-live-compat] invalid build id '${descriptor.id}': expected lowercase alphanumeric segments separated by '-'`); - } - - if (descriptor.source === AgentHostBuildSourceKind.WorkingTree) { - if (descriptor.ref !== undefined) { - throw new Error(`[agent-host-live-compat] build '${descriptor.id}' targets the working tree and must not declare a ref (got '${descriptor.ref}')`); - } - return { - id: descriptor.id, - source: descriptor.source, - description: descriptor.description, - sourceRoot: context.repoRoot, - serverEntry: join(context.repoRoot, AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH), - cacheKey: undefined, - cacheMarkerPath: undefined, - requiresWorktree: false, - }; - } - - if (!descriptor.ref) { - throw new Error(`[agent-host-live-compat] build '${descriptor.id}' targets a git ref but declares none`); - } - if (context.resolvedCommit !== undefined && !COMMIT_PATTERN.test(context.resolvedCommit)) { - throw new Error(`[agent-host-live-compat] build '${descriptor.id}' resolved to '${context.resolvedCommit}', which is not a full commit sha`); - } - - const sourceRoot = join(context.cacheRoot, 'builds', descriptor.id); - return { - id: descriptor.id, - source: descriptor.source, - ref: descriptor.ref, - resolvedCommit: context.resolvedCommit, - description: descriptor.description, - sourceRoot, - serverEntry: join(sourceRoot, AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH), - cacheKey: context.resolvedCommit === undefined ? undefined : buildCacheKey(context.resolvedCommit, context.recipeVersion), - cacheMarkerPath: join(sourceRoot, '.agent-host-live-compat-build.json'), - requiresWorktree: true, - }; -} - -function buildCacheKey(resolvedCommit: string, recipeVersion: string): string { - return `commit:${resolvedCommit}|recipe:${recipeVersion}`; -} - -/** Contents of a build's cache marker file. */ -export interface IAgentHostBuildCacheMarker { - readonly cacheKey: string; - readonly builtAt: string; -} - -export function serializeBuildCacheMarker(cacheKey: string, builtAt: string): string { - return `${JSON.stringify({ cacheKey, builtAt } satisfies IAgentHostBuildCacheMarker, undefined, '\t')}\n`; -} - -/** - * Whether a previously built output can be reused. Unreadable or malformed - * markers are treated as "not built" rather than as errors: a stale cache must - * never fail a run, it must only cost a rebuild. - */ -export function isBuildCacheUsable(plan: IAgentHostBuildPlan, markerContent: string | undefined): boolean { - if (plan.cacheKey === undefined || markerContent === undefined) { - return false; - } - try { - const marker = JSON.parse(markerContent) as Partial; - return marker.cacheKey === plan.cacheKey; - } catch { - return false; - } -} - -/** - * Explain why a planned build cannot be launched, in terms a developer can act - * on. Returns `undefined` when the build looks launchable. - */ -export function describeUnusableBuild(plan: IAgentHostBuildPlan, state: { readonly serverEntryExists: boolean; readonly cacheUsable: boolean }): string | undefined { - if (state.serverEntryExists && (state.cacheUsable || plan.cacheKey === undefined)) { - return undefined; - } - const lines = [`[agent-host-live-compat] build '${plan.id}' is not ready to launch.`]; - if (plan.description) { - lines.push(` ${plan.description}`); - } - if (!state.serverEntryExists) { - lines.push(` Missing compiled entry: ${plan.serverEntry}`); - } else { - lines.push(` Compiled output is stale for ${plan.ref ?? 'the working tree'}: ${plan.sourceRoot}`); - } - if (plan.source === AgentHostBuildSourceKind.WorkingTree) { - lines.push(' Compile the current working tree (for example `npm run transpile-client`) and re-run.'); - } else { - lines.push(` Prepare it with: node scripts/test-agent-host-live-compat.ts --prepare ${plan.id}`); - } - return lines.join('\n'); -} diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatBuilds.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatBuilds.ts deleted file mode 100644 index 805a5085068f43..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatBuilds.ts +++ /dev/null @@ -1,88 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * The named Agent Host builds live-compatibility scenarios run against. - * - * Scenarios refer to these by id only, so a checkpoint can be re-pinned to a - * newer commit without touching any scenario. Ids are ordered oldest-first. - */ - -import { tmpdir } from 'os'; -import { join } from '../../../../../../base/common/path.js'; -import { AgentHostBuildSourceKind, type IAgentHostBuildDescriptor, type IAgentHostBuildPlanContext } from './agentHostBuildPlan.js'; - -/** - * Bump when the way a historical build is compiled changes in a manner that - * invalidates already-cached outputs. - */ -export const AGENT_HOST_LIVE_COMPAT_RECIPE_VERSION = '1'; - -export const enum AgentHostBuildId { - Legacy = 'legacy', - Predecessor = 'predecessor', - Intermediate = 'intermediate', - Current = 'current', -} - -export const agentHostLiveCompatBuilds: readonly IAgentHostBuildDescriptor[] = [ - { - id: AgentHostBuildId.Legacy, - source: AgentHostBuildSourceKind.Ref, - ref: '97ed7b57c6d9becb4fe386c59157eda016050d6a', - description: 'Oldest supported Agent Host build in the compatibility matrix.', - }, - { - id: AgentHostBuildId.Predecessor, - source: AgentHostBuildSourceKind.Ref, - ref: '49f24d87cd32d2a696e469d2c61fb8d0cada4cc9', - description: 'The build immediately preceding the in-flight changes.', - }, - { - id: AgentHostBuildId.Intermediate, - source: AgentHostBuildSourceKind.Ref, - ref: '7453d67fdcde27faba527d69a535ddd51b8d1afa', - description: 'Intermediate build used to exercise multi-hop upgrades.', - }, - { - id: AgentHostBuildId.Current, - source: AgentHostBuildSourceKind.WorkingTree, - description: 'The current working tree, built in place; never checked out or reset.', - }, -]; - -export function agentHostLiveCompatBuild(id: AgentHostBuildId | string): IAgentHostBuildDescriptor { - const descriptor = agentHostLiveCompatBuilds.find(build => build.id === id); - if (!descriptor) { - throw new Error(`[agent-host-live-compat] unknown build checkpoint '${id}'; known: ${agentHostLiveCompatBuilds.map(build => build.id).join(', ')}`); - } - return descriptor; -} - -/** - * Default cache root for materialized historical worktrees and their compiled - * output. Deliberately outside the repository so a stale cache can never be - * mistaken for repository content, and overridable for CI. - */ -export function agentHostLiveCompatCacheRoot(environment: Readonly> = process.env): string { - return environment['AGENT_HOST_LIVE_COMPAT_CACHE'] || join(tmpdir(), 'vscode-agent-host-live-compat'); -} - -/** - * Build the planning context for a checkpoint. `resolveCommit` is supplied by - * the caller (the preparation script resolves refs with git; tests can pass a - * fixed sha) so that planning itself stays pure. - */ -export function agentHostLiveCompatPlanContext( - descriptor: IAgentHostBuildDescriptor, - options: { readonly repoRoot: string; readonly cacheRoot?: string; readonly resolveCommit?: (ref: string) => string | undefined }, -): IAgentHostBuildPlanContext { - return { - repoRoot: options.repoRoot, - cacheRoot: options.cacheRoot ?? agentHostLiveCompatCacheRoot(), - resolvedCommit: descriptor.ref ? options.resolveCommit?.(descriptor.ref) : undefined, - recipeVersion: AGENT_HOST_LIVE_COMPAT_RECIPE_VERSION, - }; -} diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatHarness.test.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatHarness.test.ts deleted file mode 100644 index 90d30a554f4fb1..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostLiveCompatHarness.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { join } from '../../../../../../base/common/path.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { - AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH, - AgentHostBuildSourceKind, - describeUnusableBuild, - isBuildCacheUsable, - planAgentHostBuild, - serializeBuildCacheMarker, - type IAgentHostBuildDescriptor, -} from './agentHostBuildPlan.js'; -import { - CrossVersionAgentHostTarget, - resolvePreparedBuild, - type IBuildFileSystem, -} from './crossVersionAgentHostTarget.js'; -import { agentHostLiveCompatBuild, agentHostLiveCompatBuilds, agentHostLiveCompatPlanContext } from './agentHostLiveCompatBuilds.js'; - -const COMMIT = '97ed7b57c6d9becb4fe386c59157eda016050d6a'; -const REPO_ROOT = join('/', 'repo'); -const CACHE_ROOT = join('/', 'cache'); - -function fileSystem(files: Readonly>): IBuildFileSystem { - return { - exists: path => files[path] !== undefined, - readText: path => (typeof files[path] === 'string' ? files[path] as string : undefined), - }; -} - -suite('Agent Host live-compat build planning', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const refBuild: IAgentHostBuildDescriptor = { id: 'legacy', source: AgentHostBuildSourceKind.Ref, ref: COMMIT }; - const workingTreeBuild: IAgentHostBuildDescriptor = { id: 'current', source: AgentHostBuildSourceKind.WorkingTree }; - const context = { repoRoot: REPO_ROOT, cacheRoot: CACHE_ROOT, resolvedCommit: COMMIT, recipeVersion: '1' }; - - test('a ref build is planned into the cache, a working-tree build into the repo', () => { - assert.deepStrictEqual( - [planAgentHostBuild(refBuild, context), planAgentHostBuild(workingTreeBuild, { ...context, resolvedCommit: undefined })], - [ - { - id: 'legacy', - source: AgentHostBuildSourceKind.Ref, - ref: COMMIT, - resolvedCommit: COMMIT, - description: undefined, - sourceRoot: join(CACHE_ROOT, 'builds', 'legacy'), - serverEntry: join(CACHE_ROOT, 'builds', 'legacy', AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH), - cacheKey: `commit:${COMMIT}|recipe:1`, - cacheMarkerPath: join(CACHE_ROOT, 'builds', 'legacy', '.agent-host-live-compat-build.json'), - requiresWorktree: true, - }, - { - id: 'current', - source: AgentHostBuildSourceKind.WorkingTree, - description: undefined, - sourceRoot: REPO_ROOT, - serverEntry: join(REPO_ROOT, AGENT_HOST_SERVER_ENTRY_RELATIVE_PATH), - cacheKey: undefined, - cacheMarkerPath: undefined, - requiresWorktree: false, - }, - ], - ); - }); - - test('inconsistent descriptors are rejected', () => { - assert.throws(() => planAgentHostBuild({ id: 'Legacy Build', source: AgentHostBuildSourceKind.Ref, ref: COMMIT }, context), /invalid build id/); - assert.throws(() => planAgentHostBuild({ id: 'legacy', source: AgentHostBuildSourceKind.Ref }, context), /declares none/); - assert.throws(() => planAgentHostBuild({ id: 'current', source: AgentHostBuildSourceKind.WorkingTree, ref: COMMIT }, context), /must not declare a ref/); - assert.throws(() => planAgentHostBuild(refBuild, { ...context, resolvedCommit: 'HEAD' }), /not a full commit sha/); - }); - - test('cached output is reused only for a matching commit and recipe', () => { - const plan = planAgentHostBuild(refBuild, context); - const matching = serializeBuildCacheMarker(plan.cacheKey!, '2026-01-01T00:00:00.000Z'); - assert.deepStrictEqual( - [ - isBuildCacheUsable(plan, matching), - isBuildCacheUsable(plan, serializeBuildCacheMarker(`commit:${COMMIT}|recipe:2`, 'x')), - isBuildCacheUsable(plan, 'not json'), - isBuildCacheUsable(plan, undefined), - isBuildCacheUsable(planAgentHostBuild(workingTreeBuild, { ...context, resolvedCommit: undefined }), matching), - ], - [true, false, false, false, false], - ); - }); - - test('an unusable build explains what to do about it', () => { - const plan = planAgentHostBuild(refBuild, context); - assert.strictEqual(describeUnusableBuild(plan, { serverEntryExists: true, cacheUsable: true }), undefined); - assert.match(describeUnusableBuild(plan, { serverEntryExists: false, cacheUsable: false })!, /Missing compiled entry[\s\S]*--prepare legacy/); - assert.match(describeUnusableBuild(plan, { serverEntryExists: true, cacheUsable: false })!, /stale/); - const current = planAgentHostBuild(workingTreeBuild, { ...context, resolvedCommit: undefined }); - assert.match(describeUnusableBuild(current, { serverEntryExists: false, cacheUsable: false })!, /transpile-client/); - }); - - test('checkpoints are declared for the whole matrix and resolve to plans', () => { - assert.deepStrictEqual(agentHostLiveCompatBuilds.map(build => build.id), ['legacy', 'predecessor', 'intermediate', 'current']); - const descriptor = agentHostLiveCompatBuild('intermediate'); - const plan = planAgentHostBuild(descriptor, agentHostLiveCompatPlanContext(descriptor, { repoRoot: REPO_ROOT, cacheRoot: CACHE_ROOT, resolveCommit: () => COMMIT })); - assert.deepStrictEqual( - { sourceRoot: plan.sourceRoot, cacheKey: plan.cacheKey }, - { sourceRoot: join(CACHE_ROOT, 'builds', 'intermediate'), cacheKey: `commit:${COMMIT}|recipe:1` }, - ); - assert.throws(() => agentHostLiveCompatBuild('nope'), /unknown build checkpoint/); - }); -}); - -suite('Agent Host cross-version target', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const plan = planAgentHostBuild( - { id: 'legacy', source: AgentHostBuildSourceKind.Ref, ref: COMMIT }, - { repoRoot: REPO_ROOT, cacheRoot: CACHE_ROOT, resolvedCommit: COMMIT, recipeVersion: '1' }, - ); - - test('a prepared build resolves only when compiled output matches the checkpoint', () => { - const prepared = resolvePreparedBuild(plan, fileSystem({ - [plan.serverEntry]: true, - [plan.cacheMarkerPath!]: serializeBuildCacheMarker(plan.cacheKey!, 'x'), - })); - assert.deepStrictEqual(prepared, { id: 'legacy', serverEntry: plan.serverEntry, description: COMMIT }); - assert.throws(() => resolvePreparedBuild(plan, fileSystem({})), /not ready to launch/); - assert.throws(() => resolvePreparedBuild(plan, fileSystem({ [plan.serverEntry]: true })), /stale/); - }); - - test('the target switches build selection and reports launch history', () => { - const target = new CrossVersionAgentHostTarget([ - { id: 'legacy', serverEntry: join(CACHE_ROOT, 'builds', 'legacy', 'entry.js') }, - { id: 'current', serverEntry: join(REPO_ROOT, 'entry.js') }, - ]); - assert.deepStrictEqual( - { initial: target.currentBuildId, id: target.id, launched: target.launchedBuildIds }, - { initial: 'legacy', id: 'agent-host-live-compat:legacy', launched: [] }, - ); - target.useBuild('current'); - assert.strictEqual(target.currentBuildId, 'current'); - assert.throws(() => target.useBuild('missing'), /unknown build 'missing'/); - }); - - test('the target rejects an empty or ambiguous build set', () => { - assert.throws(() => new CrossVersionAgentHostTarget([]), /at least one build/); - assert.throws(() => new CrossVersionAgentHostTarget([{ id: 'a', serverEntry: '/a' }, { id: 'a', serverEntry: '/b' }]), /duplicate build id/); - assert.throws(() => new CrossVersionAgentHostTarget([{ id: 'a', serverEntry: '/a' }], 'b'), /unknown build 'b'/); - }); - - test('stopping with nothing launched is a no-op', async () => { - const target = new CrossVersionAgentHostTarget([{ id: 'a', serverEntry: '/a' }]); - await target.stopCurrentProcess(); - await target.stopCurrentProcess(); - assert.deepStrictEqual(target.launchedBuildIds, []); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/crossVersionAgentHostTarget.ts b/src/vs/platform/agentHost/test/node/e2e/harness/crossVersionAgentHostTarget.ts deleted file mode 100644 index 43be4943d43d15..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/harness/crossVersionAgentHostTarget.ts +++ /dev/null @@ -1,190 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Cross-version ("live compatibility") Agent Host targets. - * - * A live-compat scenario runs several *phases* against one preserved isolated - * user-data directory, switching the Agent Host **build** between phases: - * - * ```ts - * const target = new CrossVersionAgentHostTarget(builds); - * target.useBuild('legacy'); // phase 1 runs on the historical build - * // ... drive AHP ... - * target.useBuild('current'); // phase 2 relaunches on the current build - * await harness.restart(); // … same homeDir/userDataDir/replay proxy - * ``` - * - * Everything else is unchanged: assertions stay over AHP, and model traffic - * still goes through the same `CapiReplayProxy` instance, so replay remains - * strict and host-only tests still hard-fail on an unexpected model request. - * - * This file only *launches* builds. Materializing and compiling them is the - * job of `scripts/test-agent-host-live-compat.ts`; a build that has not been - * prepared produces an actionable error here rather than a module-not-found - * crash inside a forked child. - */ - -import { existsSync, readFileSync } from 'fs'; -import { startRealServer, stopServer, type IServerHandle } from '../../serverIntegrationTestHelpers.js'; -import { - AgentHostBuildSourceKind, - describeUnusableBuild, - isBuildCacheUsable, - planAgentHostBuild, - type IAgentHostBuildDescriptor, - type IAgentHostBuildPlan, - type IAgentHostBuildPlanContext, -} from './agentHostBuildPlan.js'; -import type { IAgentHostTarget, IAgentHostTargetLaunchOptions } from './agentHostTarget.js'; - -/** - * A single prepared build the suite can launch. Produced by - * {@link resolvePreparedBuild} from a descriptor, or constructed directly in - * tests that want to exercise the target without compiling anything. - */ -export interface IPreparedAgentHostBuild { - readonly id: string; - /** Absolute path of the compiled agent host server entry to fork. */ - readonly serverEntry: string; - /** Human readable provenance for diagnostics, e.g. a commit sha. */ - readonly description?: string; -} - -/** Filesystem probes the resolver needs; injectable so the rules are testable. */ -export interface IBuildFileSystem { - readonly exists: (path: string) => boolean; - readonly readText: (path: string) => string | undefined; -} - -export const realBuildFileSystem: IBuildFileSystem = { - exists: path => existsSync(path), - readText: path => { - try { - return readFileSync(path, 'utf8'); - } catch { - return undefined; - } - }, -}; - -/** - * Turn a planned build into a launchable one, or explain precisely what is - * missing. Never builds anything: preparation is an explicit, scriptable step. - */ -export function resolvePreparedBuild(plan: IAgentHostBuildPlan, fileSystem: IBuildFileSystem = realBuildFileSystem): IPreparedAgentHostBuild { - const cacheUsable = plan.cacheMarkerPath === undefined - ? plan.source === AgentHostBuildSourceKind.WorkingTree - : isBuildCacheUsable(plan, fileSystem.readText(plan.cacheMarkerPath)); - const problem = describeUnusableBuild(plan, { serverEntryExists: fileSystem.exists(plan.serverEntry), cacheUsable }); - if (problem) { - throw new Error(problem); - } - return { - id: plan.id, - serverEntry: plan.serverEntry, - description: plan.description ?? plan.resolvedCommit ?? plan.ref, - }; -} - -export function resolvePreparedBuilds( - descriptors: readonly IAgentHostBuildDescriptor[], - context: (descriptor: IAgentHostBuildDescriptor) => IAgentHostBuildPlanContext, - fileSystem: IBuildFileSystem = realBuildFileSystem, -): readonly IPreparedAgentHostBuild[] { - return descriptors.map(descriptor => resolvePreparedBuild(planAgentHostBuild(descriptor, context(descriptor)), fileSystem)); -} - -/** - * An {@link IAgentHostTarget} whose underlying build can be switched between - * phases of a scenario. Launching always goes through the same code path as - * the default target, so the persistent dirs and the replay proxy handed in by - * the harness are honored identically on every build. - */ -export class CrossVersionAgentHostTarget implements IAgentHostTarget { - - private readonly _builds = new Map(); - private _current: IPreparedAgentHostBuild; - private _lastLaunched: IServerHandle | undefined; - private readonly _launchedBuildIds: string[] = []; - - constructor(builds: readonly IPreparedAgentHostBuild[], initialBuildId?: string) { - if (builds.length === 0) { - throw new Error('[agent-host-live-compat] a cross-version target needs at least one build'); - } - for (const build of builds) { - if (this._builds.has(build.id)) { - throw new Error(`[agent-host-live-compat] duplicate build id '${build.id}'`); - } - this._builds.set(build.id, build); - } - this._current = initialBuildId ? this._lookup(initialBuildId) : builds[0]; - } - - get id(): string { - return `agent-host-live-compat:${this._current.id}`; - } - - /** The build id the next launch will use. */ - get currentBuildId(): string { - return this._current.id; - } - - /** Build ids actually launched so far, in order. Useful for phase assertions. */ - get launchedBuildIds(): readonly string[] { - return this._launchedBuildIds; - } - - /** - * Select the build subsequent launches use. The caller still drives the - * relaunch (typically `harness.restart()`), which is what preserves the - * user-data directory and the replay stream across the switch. - */ - useBuild(buildId: string): void { - this._current = this._lookup(buildId); - } - - async launch(options: IAgentHostTargetLaunchOptions): Promise { - // Switching builds against a shared user-data directory is only safe once - // the previous process has fully exited and released its state. - await this.stopCurrentProcess(); - const build = this._current; - const server = await startRealServer({ - serverEntry: build.serverEntry, - homeDir: options.homeDir, - userDataDir: options.userDataDir, - codexHomeDir: options.codexHomeDir, - capiReplay: options.capiReplay, - existingCapiReplay: options.existingCapiReplay, - claudeSdkRoot: options.claudeSdkRoot, - codexSdkRoot: options.codexSdkRoot, - logLevel: options.logLevel, - env: options.env, - }); - this._lastLaunched = server; - this._launchedBuildIds.push(build.id); - return server; - } - - /** - * Await full shutdown of the process this target last launched. Safe to - * call when nothing is running, and idempotent. - */ - async stopCurrentProcess(): Promise { - const previous = this._lastLaunched; - this._lastLaunched = undefined; - if (previous) { - await stopServer(previous); - } - } - - private _lookup(buildId: string): IPreparedAgentHostBuild { - const build = this._builds.get(buildId); - if (!build) { - throw new Error(`[agent-host-live-compat] unknown build '${buildId}'; prepared builds: ${[...this._builds.keys()].join(', ')}`); - } - return build; - } -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.test.ts deleted file mode 100644 index 3e0a87401f8830..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { - compareProtocolVersions, - createAgentHostCapabilityAdapter, - LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, -} from './agentHostLiveCompatCapabilities.js'; -import type { AgentProviderCapabilities } from './agentHostLiveCompatProtocol.js'; - -function adapterFor(protocolVersion: string, providers: Readonly> = {}) { - return createAgentHostCapabilityAdapter({ - protocolVersion, - providerCapabilities: new Map(Object.entries(providers)), - }); -} - -suite('Agent Host live-compat capability adapter', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('capabilities come from what the build advertises, not from its checkpoint', () => { - const legacy = adapterFor('0.8.0', { mock: {}, copilotcli: { multipleChats: { fork: true } } }); - const current = adapterFor('1.0.0', { mock: {}, copilotcli: { multipleChats: { fork: true } } }); - assert.deepStrictEqual( - [legacy, current].map(adapter => ({ - protocolVersion: adapter.protocolVersion, - rename: adapter.supportsSessionRename, - peerOnMock: adapter.supportsPeerChats('mock'), - peerOnCopilot: adapter.supportsPeerChats('copilotcli'), - peerOnUnknown: adapter.supportsPeerChats('not-registered'), - })), - [ - { protocolVersion: '0.8.0', rename: true, peerOnMock: false, peerOnCopilot: true, peerOnUnknown: false }, - { protocolVersion: '1.0.0', rename: true, peerOnMock: false, peerOnCopilot: true, peerOnUnknown: false }, - ], - ); - }); - - test('a build older than the whole matrix degrades rename instead of asserting on it', () => { - assert.strictEqual(adapterFor('0.4.0').supportsSessionRename, false); - assert.strictEqual(adapterFor('0.5.1').supportsSessionRename, true); - }); - - test('the offered version list is ordered newest-first and covers every checkpoint in the matrix', () => { - const ordered = [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS] - .every((version, index, all) => index === 0 || compareProtocolVersions(all[index - 1], version) > 0); - assert.deepStrictEqual( - { - ordered, - // The versions the four prepared builds actually negotiate today. - offersLegacy: LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS.includes('0.8.0'), - offersCurrent: LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS.includes('1.0.0'), - }, - { ordered: true, offersLegacy: true, offersCurrent: true }, - ); - }); - - test('protocol versions compare by precedence, and malformed input is rejected', () => { - assert.deepStrictEqual( - [ - Math.sign(compareProtocolVersions('1.0.0', '0.8.0')), - Math.sign(compareProtocolVersions('0.8.0', '1.0.0')), - Math.sign(compareProtocolVersions('0.8.0', '0.8.0')), - Math.sign(compareProtocolVersions('0.10.0', '0.9.0')), - Math.sign(compareProtocolVersions('0.8.2', '0.8.10')), - ], - [1, -1, 0, 1, -1], - ); - assert.throws(() => compareProtocolVersions('1.0', '1.0.0'), /not a protocol version/); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.ts deleted file mode 100644 index 1450123d753a7c..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatCapabilities.ts +++ /dev/null @@ -1,115 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * The external capability adapter for live-compatibility scenarios. - * - * A live-compat scenario runs the *same* script against Agent Host builds that - * are months apart, so the script inevitably meets contract evolution: an older - * build negotiates an older protocol version, and a feature the current build - * has may not exist there at all. - * - * The rule this file exists to enforce is that such differences are resolved - * **once, externally, from what the build advertises over AHP** — never with - * `if (buildId === 'legacy')` branches sprinkled through scenario bodies. A - * scenario asks the adapter a question ("can I create a peer chat here?") and - * the adapter answers from the handshake and the root snapshot, exactly as any - * real AHP client would have to. - * - * Consequently nothing here reads the repository, imports host internals, or - * consults the checkpoint id. Adding a fifth checkpoint must require no change - * to this file; adding a *capability* is the only reason to touch it. - */ - -import type { AgentProviderCapabilities } from './agentHostLiveCompatProtocol.js'; - -/** - * Every protocol version this suite is willing to negotiate, most preferred - * first. - * - * Deliberately a literal list rather than an import of the working tree's - * `SUPPORTED_PROTOCOL_VERSIONS`: the suite plays the role of a *client* that - * must interoperate with all four builds, and the oldest of them predates - * entries the current tree advertises. Pinning the list here keeps the offer - * stable when the working tree's own list moves. - */ -export const LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - '1.0.0', - '0.8.0', - '0.7.0', - '0.6.0', - '0.5.2', - '0.5.1', -]); - -/** What a build advertised, as observed over AHP. */ -export interface IAgentHostAdvertisedSurface { - /** Version the `initialize` handshake settled on. */ - readonly protocolVersion: string; - /** Provider capabilities from the root snapshot, keyed by provider id. */ - readonly providerCapabilities: ReadonlyMap; -} - -/** - * The questions a scenario is allowed to ask about the build it is driving. - * - * Each is derived from {@link IAgentHostAdvertisedSurface}, so a scenario's - * behavior is a function of the advertised contract rather than of which - * checkpoint happens to be running. - */ -export interface IAgentHostCapabilityAdapter { - readonly protocolVersion: string; - /** Whether `session/titleChanged` may be dispatched for a readable rename. */ - readonly supportsSessionRename: boolean; - /** - * Whether `createChat` may be called against `provider`. False when the - * provider does not advertise `multipleChats`, in which case a peer-chat - * step must be skipped rather than expected to fail. - */ - supportsPeerChats(provider: string): boolean; -} - -/** - * Minimum negotiated protocol version that carries `session/titleChanged` as a - * client-dispatchable action. Every checkpoint in the matrix is at or above it - * today; the check exists so a future older checkpoint degrades into a skipped - * step with a stated reason instead of a mystery assertion failure. - */ -const MIN_PROTOCOL_VERSION_FOR_RENAME = '0.5.1'; - -export function createAgentHostCapabilityAdapter(surface: IAgentHostAdvertisedSurface): IAgentHostCapabilityAdapter { - return { - protocolVersion: surface.protocolVersion, - supportsSessionRename: compareProtocolVersions(surface.protocolVersion, MIN_PROTOCOL_VERSION_FOR_RENAME) >= 0, - supportsPeerChats: provider => surface.providerCapabilities.get(provider)?.multipleChats !== undefined, - }; -} - -/** - * Compares two `MAJOR.MINOR.PATCH` protocol versions. Returns a negative - * number when `left` is older, zero when equal, positive when newer. - * - * A local implementation rather than an import: the working tree's comparator - * is part of the code under test, and a compatibility suite that borrowed it - * would stop being able to detect a regression in it. - */ -export function compareProtocolVersions(left: string, right: string): number { - const leftParts = parseProtocolVersion(left); - const rightParts = parseProtocolVersion(right); - for (let index = 0; index < 3; index++) { - if (leftParts[index] !== rightParts[index]) { - return leftParts[index] - rightParts[index]; - } - } - return 0; -} - -function parseProtocolVersion(version: string): readonly [number, number, number] { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); - if (!match) { - throw new Error(`[agent-host-live-compat] not a protocol version: '${version}'`); - } - return [Number(match[1]), Number(match[2]), Number(match[3])]; -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatClient.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatClient.ts deleted file mode 100644 index 3c4d1bccd12f15..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatClient.ts +++ /dev/null @@ -1,144 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * A minimal AHP client for live-compatibility scenarios. - * - * The E2E suite's `TestProtocolClient` is the richer client — snapshots, reverse - * requests, notification waiters — but it is built for a Mocha test process and - * transitively pulls in the snapshot module, which installs `setup`/`teardown` - * at import time. Live-compat baselines are driven from a plain `node` script, - * so importing it there would fail before a single build was launched. - * - * That constraint turns out to be the right shape anyway. The suite's governing - * principle is that the implementation is reached *only* over the Agent Host - * Protocol on a WebSocket; this client is that seam and nothing else. It speaks - * JSON-RPC 2.0, serves no reverse requests, and knows nothing about any host - * type — which is precisely the position a real third-party client is in when - * it meets a build from six months ago. - * - * Reverse requests are answered with a method-not-found error rather than - * ignored: a baseline never asks the host to touch client-side files, so a - * reverse request arriving at all is a signal worth surfacing, and leaving it - * unanswered would instead hang the host until its own timeout. - */ - -import { WebSocket } from 'ws'; - -/** JSON-RPC error surfaced by the host. */ -export class LiveCompatProtocolError extends Error { - constructor(readonly code: number, message: string) { - super(message); - } -} - -const JSON_RPC_METHOD_NOT_FOUND = -32601; - -interface IPendingCall { - readonly resolve: (value: unknown) => void; - readonly reject: (error: Error) => void; - readonly timer: ReturnType; -} - -export class LiveCompatAhpClient { - private readonly _socket: WebSocket; - private readonly _pending = new Map(); - private _nextId = 1; - private _closed = false; - - constructor(port: number) { - this._socket = new WebSocket(`ws://127.0.0.1:${port}`); - } - - connect(): Promise { - return new Promise((resolve, reject) => { - this._socket.on('error', reject); - this._socket.on('open', () => { - this._socket.on('message', data => this._receive(data.toString())); - // A socket that drops mid-scenario must fail the outstanding call - // rather than let it sit until the per-call timeout. - this._socket.on('close', () => this._failAllPending(new Error('[agent-host-live-compat] the host closed the connection'))); - resolve(); - }); - }); - } - - /** Send a JSON-RPC request and await its response. */ - call(method: string, params: unknown, timeoutMs: number): Promise { - if (this._closed) { - return Promise.reject(new Error(`[agent-host-live-compat] '${method}' on a closed connection`)); - } - const id = this._nextId++; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this._pending.delete(id); - reject(new Error(`[agent-host-live-compat] timed out after ${timeoutMs}ms waiting for '${method}'`)); - }, timeoutMs); - this._pending.set(id, { resolve: value => resolve(value as T), reject, timer }); - try { - this._socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); - } catch (error) { - this._pending.delete(id); - clearTimeout(timer); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - } - - /** Send a fire-and-forget JSON-RPC notification. */ - notify(method: string, params: unknown): void { - this._socket.send(JSON.stringify({ jsonrpc: '2.0', method, params })); - } - - close(): void { - if (this._closed) { - return; - } - this._closed = true; - this._failAllPending(new Error('[agent-host-live-compat] the client closed the connection')); - this._socket.close(); - } - - private _receive(text: string): void { - const message = JSON.parse(text) as { - id?: number; - method?: string; - result?: unknown; - error?: { code: number; message: string }; - }; - if (message.id !== undefined && message.method !== undefined) { - this._socket.send(JSON.stringify({ - jsonrpc: '2.0', - id: message.id, - error: { code: JSON_RPC_METHOD_NOT_FOUND, message: `[agent-host-live-compat] reverse request '${message.method}' is not served by the baseline client` }, - })); - return; - } - if (message.id === undefined) { - // A server notification. Baselines assert on readbacks, not on the - // notification stream, so there is nothing to accumulate. - return; - } - const pending = this._pending.get(message.id); - if (!pending) { - return; - } - this._pending.delete(message.id); - clearTimeout(pending.timer); - if (message.error) { - pending.reject(new LiveCompatProtocolError(message.error.code, message.error.message)); - } else { - pending.resolve(message.result); - } - } - - private _failAllPending(error: Error): void { - for (const [id, pending] of this._pending) { - this._pending.delete(id); - clearTimeout(pending.timer); - pending.reject(error); - } - } -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatMatrix.ts deleted file mode 100644 index 24500f3e3cc912..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatMatrix.ts +++ /dev/null @@ -1,130 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Executes live-compatibility scenarios across the prepared build matrix and - * summarizes the outcome. - * - * Two rules shape this file: - * - * - **A build is never silently skipped.** A checkpoint that cannot even be - * resolved is reported as a failed entry carrying the resolver's own - * explanation, so a run that covered three of four builds can never be - * mistaken for a run that covered four. - * - **Builds run sequentially.** Each scenario forks a real Agent Host and, for - * the historical checkpoints, that process is a different compiled tree - * sharing this machine's temp space. Serializing keeps a failure attributable - * to one build instead of to contention between them. - */ - -import { existsSync, readFileSync } from 'fs'; -import { agentHostLiveCompatBuild, agentHostLiveCompatPlanContext, type AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; -import { AgentHostBuildSourceKind, describeUnusableBuild, isBuildCacheUsable, planAgentHostBuild } from '../harness/agentHostBuildPlan.js'; -import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; -import { runSameBuildRestartBaseline, type ILiveCompatScenarioResult } from './sameBuildRestartBaseline.js'; - -export interface ILiveCompatMatrixOptions { - readonly repoRoot: string; - /** Resolves a checkpoint ref to a full commit sha; supplied by the caller. */ - readonly resolveCommit: (ref: string) => string | undefined; - readonly cacheRoot?: string; - readonly diagnosticsRoot?: string; -} - -/** Aggregate outcome of one live-compat run. */ -export interface ILiveCompatMatrixSummary { - readonly suite: string; - readonly startedAt: string; - readonly durationMs: number; - readonly outcome: 'passed' | 'failed'; - readonly results: readonly ILiveCompatScenarioResult[]; -} - -/** - * Run the same-build restart baseline for each requested checkpoint, in order. - */ -export async function runSameBuildRestartBaselines( - buildIds: readonly (AgentHostBuildId | string)[], - options: ILiveCompatMatrixOptions, -): Promise { - const startedAt = Date.now(); - const results: ILiveCompatScenarioResult[] = []; - for (const buildId of buildIds) { - results.push(await runOne(buildId, options)); - } - return { - suite: 'agent-host-live-compat/same-build-restart-baseline', - startedAt: new Date(startedAt).toISOString(), - durationMs: Date.now() - startedAt, - outcome: results.every(result => result.outcome === 'passed') ? 'passed' : 'failed', - results, - }; -} - -async function runOne(buildId: AgentHostBuildId | string, options: ILiveCompatMatrixOptions): Promise { - const startedAt = Date.now(); - let prepared: IPreparedAgentHostBuild; - try { - prepared = resolveBuild(buildId, options); - } catch (error) { - // Resolution failure is a real, reportable result — the whole point of - // requirement "no silent skips" — and carries the resolver's actionable - // message (which names the exact `--prepare` command to run). - return { - scenario: 'same-build-restart-baseline', - build: String(buildId), - outcome: 'failed', - durationMs: Date.now() - startedAt, - steps: [{ name: 'resolve-build', outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }], - diagnosticsPath: '', - error: messageOf(error), - }; - } - return runSameBuildRestartBaseline(prepared, { diagnosticsRoot: options.diagnosticsRoot }); -} - -/** - * Resolve a checkpoint into a launchable build, or explain what is missing. - * - * This repeats the few lines of `resolvePreparedBuild` rather than calling it, - * for an import-graph reason worth stating: that function lives in - * `crossVersionAgentHostTarget.ts`, which imports the Mocha-oriented server - * helper and therefore cannot be loaded from a plain `node` process. The rules - * themselves are not duplicated — `isBuildCacheUsable` and - * `describeUnusableBuild` remain the single source of truth for what makes a - * build usable and what to tell the developer about it. - */ -export function resolveBuild(buildId: AgentHostBuildId | string, options: ILiveCompatMatrixOptions): IPreparedAgentHostBuild { - const descriptor = agentHostLiveCompatBuild(buildId); - const plan = planAgentHostBuild(descriptor, agentHostLiveCompatPlanContext(descriptor, { - repoRoot: options.repoRoot, - cacheRoot: options.cacheRoot, - resolveCommit: options.resolveCommit, - })); - const cacheUsable = plan.cacheMarkerPath === undefined - ? plan.source === AgentHostBuildSourceKind.WorkingTree - : isBuildCacheUsable(plan, readTextOrUndefined(plan.cacheMarkerPath)); - const problem = describeUnusableBuild(plan, { serverEntryExists: existsSync(plan.serverEntry), cacheUsable }); - if (problem) { - throw new Error(problem); - } - return { - id: plan.id, - serverEntry: plan.serverEntry, - description: plan.description ?? plan.resolvedCommit ?? plan.ref, - }; -} - -function readTextOrUndefined(path: string): string | undefined { - try { - return readFileSync(path, 'utf8'); - } catch { - return undefined; - } -} - -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatProtocol.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatProtocol.ts deleted file mode 100644 index ae05e0d180c920..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatProtocol.ts +++ /dev/null @@ -1,64 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * The wire shapes live-compat scenarios read off AHP. - * - * These are deliberately **not** imports of the working tree's protocol types. - * A scenario here drives four builds at once, and the working tree's types - * describe only the newest of them: typing an older build's payload with them - * would quietly promise fields that build never sends. Worse, the protocol - * types are part of the code under test, so a compatibility suite that - * borrowed them could not detect a breaking change to them. - * - * So each interface below is a hand-written, *narrow* description of exactly - * the fields the suite asserts on, with everything optional that any build in - * the matrix may omit. Widening one is a deliberate act that says "the suite - * now depends on this field being present on every supported build". - */ - -/** `initialize` result, narrowed to the fields the suite reads. */ -export interface ILiveCompatInitializeResult { - readonly protocolVersion: string; -} - -/** One entry of `listSessions`, narrowed to durable identity and metadata. */ -export interface ILiveCompatSessionListItem { - readonly resource: string; - readonly provider?: string; - readonly title?: string; -} - -/** `listSessions` result. */ -export interface ILiveCompatSessionList { - readonly items?: readonly ILiveCompatSessionListItem[]; -} - -/** `subscribe` result carrying a channel snapshot. */ -export interface ILiveCompatSubscribeResult { - readonly snapshot?: { readonly state?: ILiveCompatChannelState }; -} - -/** Union of the session/root state fields the suite reads. */ -export interface ILiveCompatChannelState { - readonly title?: string; - readonly chats?: readonly { readonly resource: string }[]; - readonly agents?: readonly ILiveCompatAgentDescriptor[]; -} - -/** A provider as advertised on the root channel. */ -export interface ILiveCompatAgentDescriptor { - readonly provider: string; - readonly capabilities?: AgentProviderCapabilities; -} - -/** - * Provider capabilities the adapter interprets. Presence is the signal; the - * inner shape is irrelevant to every question the suite asks, so it is left - * unmodelled rather than mirrored inaccurately across four builds. - */ -export interface AgentProviderCapabilities { - readonly multipleChats?: object; -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatServer.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatServer.ts deleted file mode 100644 index 934c1938bf668e..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/agentHostLiveCompatServer.ts +++ /dev/null @@ -1,151 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Launching an Agent Host build for a live-compatibility scenario. - * - * This is a separate launcher from `startRealServer` on purpose. That helper - * exists to stand up a *bundled provider* against the record/replay proxy, and - * carries the whole apparatus that goes with it: a mock CAPI upstream, minted - * Copilot tokens, SDK-root overrides, coverage plumbing. A restart baseline - * needs none of it — it must not contact a model at all — and inheriting that - * apparatus would make the baseline's result depend on fixture state that has - * nothing to do with whether a build can reopen its own profile. - * - * What it keeps is the part that matters for compatibility: the build's server - * entry is forked as a real process, isolated onto the supplied home and - * user-data directories, and reached only over the socket it advertises. The - * scripted mock provider is enabled through the same `--enable-mock-agent` - * flag every checkpoint in the matrix already supports. - */ - -import { fork, type ChildProcess } from 'child_process'; -import { join } from '../../../../../../base/common/path.js'; - -/** A launched build: the forked process and the port it advertised. */ -export interface ILiveCompatServerHandle { - readonly process: ChildProcess; - readonly port: number; -} - -export interface ILiveCompatLaunchOptions { - /** Absolute path of the compiled `agentHostServerMain.js` to fork. */ - readonly serverEntry: string; - /** Home directory the build must confine provider configuration to. */ - readonly homeDir: string; - /** User-data directory the build must confine its own state to. */ - readonly userDataDir: string; - /** Extra environment for the child process. */ - readonly env?: Readonly>; -} - -const STARTUP_TIMEOUT_MS = 60_000; -const SHUTDOWN_TIMEOUT_MS = 30_000; - -/** - * Fork a build's server and resolve once it advertises its port. - * - * The child is started on an ephemeral port (`--port 0`) so several builds can - * be exercised without coordinating a port range, and without a connection - * token because the socket never leaves the loopback interface. - */ -export function startLiveCompatServer(options: ILiveCompatLaunchOptions): Promise { - return new Promise((resolve, reject) => { - let child: ChildProcess; - try { - child = fork(options.serverEntry, [ - '--port', '0', - '--without-connection-token', - '--enable-mock-agent', - '--user-data-dir', options.userDataDir, - // The host's own logs are the primary diagnostic when a baseline - // fails, and they are written under the retained user-data dir. - '--log', 'trace', - ], { - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - env: isolatedEnvironment(options), - }); - } catch (error) { - reject(error); - return; - } - - const timer = setTimeout(() => { - child.kill(); - reject(new Error(`[agent-host-live-compat] ${options.serverEntry} did not become ready within ${STARTUP_TIMEOUT_MS}ms`)); - }, STARTUP_TIMEOUT_MS); - - const settleWith = (outcome: () => void): void => { - clearTimeout(timer); - child.stdout?.removeAllListeners('data'); - outcome(); - }; - - child.stdout?.on('data', (data: Buffer) => { - const match = /READY:(\d+)/.exec(data.toString()); - if (match) { - settleWith(() => resolve({ process: child, port: Number(match[1]) })); - } - }); - // Swallowed deliberately: the child's diagnostics belong in its log file - // under the retained user-data directory, and the integration runner - // fails a test on unexpected console output. - child.stderr?.on('data', () => { }); - child.on('error', error => settleWith(() => reject(error))); - child.on('exit', code => settleWith(() => reject(new Error(`[agent-host-live-compat] ${options.serverEntry} exited with code ${code} before becoming ready`)))); - }); -} - -/** - * Confine the build to the scenario's directories. - * - * Ambient provider configuration is cleared rather than merely overridden: a - * developer's real `CLAUDE_CONFIG_DIR` or `CODEX_HOME` would otherwise leak - * local sessions into a run whose entire subject is which sessions survive. - */ -function isolatedEnvironment(options: ILiveCompatLaunchOptions): NodeJS.ProcessEnv { - return { - ...process.env, - HOME: options.homeDir, - USERPROFILE: options.homeDir, - XDG_CONFIG_HOME: join(options.homeDir, '.config'), - XDG_DATA_HOME: join(options.homeDir, '.local', 'share'), - CLAUDE_CONFIG_DIR: join(options.homeDir, '.claude'), - CODEX_HOME: join(options.homeDir, '.codex'), - COPILOT_HOME: join(options.homeDir, '.copilot'), - ...options.env, - }; -} - -/** - * Stop a launched build and wait for the process to actually exit. - * - * Awaiting the exit is the load-bearing part: the next phase reuses the same - * user-data directory, and a still-running predecessor would hold the state it - * is supposed to have handed over — turning a persistence result into a race. - * Shutdown is requested by closing stdin (the host's own signal), and escalated - * to a kill only if the process overstays, so a build that hangs on shutdown - * still yields a result rather than stalling the matrix. - */ -export async function stopLiveCompatServer(server: ILiveCompatServerHandle | undefined): Promise { - const child = server?.process; - if (!child || child.exitCode !== null || child.signalCode !== null) { - return; - } - const exited = new Promise(resolve => child.once('exit', () => resolve())); - child.stdin?.end(); - let timer: ReturnType | undefined; - const timedOut = new Promise<'timeout'>(resolve => { - timer = setTimeout(() => resolve('timeout'), SHUTDOWN_TIMEOUT_MS); - }); - try { - if (await Promise.race([exited.then(() => 'exited' as const), timedOut]) === 'timeout') { - child.kill('SIGKILL'); - await exited; - } - } finally { - clearTimeout(timer); - } -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.test.ts deleted file mode 100644 index bc7b6372ef76f3..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { describeIdentityMismatch } from './backwardCompatibilityMatrix.js'; -import { BACKWARD_COMPAT_OLDER_BUILDS } from './runBackwardCompatibilityMatrix.js'; - -const A = 'mock:/session-a'; -const B = 'mock:/session-b'; - -suite('Agent Host backward-compat identity rule', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('a listing that matches the expectation exactly is accepted', () => { - assert.strictEqual( - describeIdentityMismatch( - [{ resource: A, title: 'from older build' }, { resource: B, title: 'also older' }], - [{ resource: A, title: 'from older build' }, { resource: B, title: 'also older' }], - ), - undefined, - ); - }); - - test('order is not identity: the same set listed in reverse still matches', () => { - assert.strictEqual( - describeIdentityMismatch([{ resource: B }, { resource: A }], [{ resource: A }, { resource: B }]), - undefined, - ); - }); - - test('a duplicated identity is reported as duplication, not as an extra session', () => { - // The signature downgrade defect: an older build re-adopts the same chat - // under a second row, which the returning build then reports twice. - assert.match( - describeIdentityMismatch([{ resource: A }, { resource: A }], [{ resource: A }])!, - /more than once: mock:\/session-a x2/, - ); - }); - - test('missing and unexpected identities are both named', () => { - assert.deepStrictEqual( - [ - describeIdentityMismatch([{ resource: A }], [{ resource: A }, { resource: B }]), - describeIdentityMismatch([{ resource: A }, { resource: B }], [{ resource: A }]), - ], - [ - 'listed sessions do not match: missing [mock:/session-b], unexpected []', - 'listed sessions do not match: missing [], unexpected [mock:/session-b]', - ], - ); - }); - - test('a title reverted by the returning build fails even though the session survived', () => { - assert.match( - describeIdentityMismatch( - [{ resource: A, title: 'Backward Compat Seed' }], - [{ resource: A, title: 'Renamed By Older Build' }], - )!, - /should carry title "Renamed By Older Build" but carries "Backward Compat Seed"/, - ); - }); - - test('titles are only asserted when the expectation states one', () => { - assert.strictEqual( - describeIdentityMismatch([{ resource: A, title: 'anything at all' }], [{ resource: A }]), - undefined, - ); - }); - - test('an empty profile matches an empty expectation', () => { - assert.strictEqual(describeIdentityMismatch([], []), undefined); - }); - - test('every older checkpoint is covered, oldest first, and the current build is not paired with itself', () => { - assert.deepStrictEqual([...BACKWARD_COMPAT_OLDER_BUILDS], ['legacy', 'intermediate', 'predecessor']); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.ts deleted file mode 100644 index 1e11c7662029d4..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/backwardCompatibilityMatrix.ts +++ /dev/null @@ -1,627 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Backward-compatibility ("downgrade") and round-trip scenarios. - * - * The same-build restart baseline establishes that each checkpoint can reopen - * *its own* profile. This file asks the harder question that a real user asks - * by accident: what happens when a profile written by the **newest** build is - * then opened by an **older** one, and afterwards handed back? - * - * ```text - * phase 1: current phase 2: older build phase 3: current returns - * create A ─▶ rename A ─▶ list A ─▶ rename A ─▶ list {A, B} exactly once - * create B A keeps the OLD build's title - * subscribe A and B - * ─▶ restart ─▶ still stable - * ``` - * - * Three properties make the result meaningful: - * - * - **One profile throughout.** Every phase receives the identical home and - * user-data directory. A fresh profile anywhere would make the whole scenario - * vacuous, so the directories are created once, up front, and only passed - * around afterwards. - * - **Exactly-once identity.** The interesting downgrade failure is not a lost - * session but a *duplicated* one: an older build that cannot parse the newer - * catalogue may re-adopt the same underlying chat under a second identity, - * which then reappears as a phantom row when the newer build returns. The - * assertion is therefore on the exact multiset of resources, never on - * "contains". - * - **The older build's writes are authoritative.** Phase 3 requires the title - * the *older* build set, not the one the newer build seeded. A newer build - * that silently reverts to its own last-known value would still pass a naive - * "the session survived" check while destroying user edits. - * - * As with the baseline, everything is reached over AHP against real forked - * processes running the scripted mock provider: no host internals, no database - * reads, no log scraping, no model traffic. - */ - -import { mkdirSync, mkdtempSync } from 'fs'; -import { tmpdir } from 'os'; -import { timeout } from '../../../../../../base/common/async.js'; -import { join } from '../../../../../../base/common/path.js'; -import { LiveCompatAhpClient } from './agentHostLiveCompatClient.js'; -import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; -import { - createAgentHostCapabilityAdapter, - LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, - type IAgentHostCapabilityAdapter, -} from './agentHostLiveCompatCapabilities.js'; -import { startLiveCompatServer, stopLiveCompatServer, type ILiveCompatLaunchOptions, type ILiveCompatServerHandle } from './agentHostLiveCompatServer.js'; -import type { - AgentProviderCapabilities, - ILiveCompatInitializeResult, - ILiveCompatSessionList, - ILiveCompatSessionListItem, - ILiveCompatSubscribeResult, -} from './agentHostLiveCompatProtocol.js'; -import type { ILiveCompatStepResult } from './sameBuildRestartBaseline.js'; - -/** Root channel URI. A constant of the protocol, stable across every build. */ -const ROOT_CHANNEL = 'ahp-root://'; -/** Provider driven by every phase; see the baseline's header for why it is the mock. */ -const PROVIDER = 'mock'; -/** Title the newest build seeds in phase 1. */ -const CURRENT_SEED_TITLE = 'Backward Compat Seed'; -/** Title the *older* build overwrites it with in phase 2; phase 3 must see this one. */ -const OLDER_BUILD_TITLE = 'Renamed By Older Build'; -/** Title the older build gives the session it creates in phase 2. */ -const OLDER_BUILD_SECOND_TITLE = 'Created By Older Build'; - -const PER_CALL_TIMEOUT_MS = 30_000; - -/** See the baseline: a restored session is not describable the instant the socket opens. */ -const RESTORE_ATTEMPTS = 20; -const RESTORE_RETRY_DELAY_MS = 500; - -/** - * Budget for the returning build to converge on the older build's writes. - * - * Larger than the restore budget because convergence waits on a background - * reconciliation pass rather than on catalogue restore alone. - */ -const CONVERGENCE_ATTEMPTS = 60; - -/** - * Time allowed for catalogue writes to reach disk before a host is stopped. - * - * Identical in purpose to the baseline's window and load-bearing for the same - * reason: `listSessions` and `subscribe` are both served from memory, and the - * catalogue write that makes a create or rename durable is queued behind them - * with no AHP acknowledgment and no shutdown flush. Without this window a - * cross-build handover cannot distinguish "the older build could not read it" - * from "it was never written before the process stopped" — which is precisely - * the confusion this suite exists to eliminate. - */ -const HANDOVER_SETTLE_MS = 1_000; - -/** A session identity the matrix expects to observe, and the title it must carry. */ -export interface IExpectedSessionIdentity { - readonly resource: string; - /** Expected title, or `undefined` when titles are not asserted on this build. */ - readonly title?: string; -} - -/** Machine-readable result of one downgrade round trip. */ -export interface IBackwardCompatScenarioResult { - readonly scenario: string; - /** Build that seeds and later re-reads the profile. */ - readonly currentBuild: string; - /** Older build the profile is handed down to. */ - readonly olderBuild: string; - readonly olderBuildDescription?: string; - readonly outcome: 'passed' | 'failed'; - readonly durationMs: number; - /** Protocol version negotiated by the newest build. */ - readonly currentProtocolVersion?: string; - /** Protocol version negotiated by the older build. */ - readonly olderProtocolVersion?: string; - readonly steps: readonly ILiveCompatStepResult[]; - /** Retained profile + host logs for both builds. Never deleted. */ - readonly diagnosticsPath: string; - readonly error?: string; -} - -/** Aggregate outcome of a backward-compatibility run. */ -export interface IBackwardCompatMatrixSummary { - readonly suite: string; - readonly startedAt: string; - readonly durationMs: number; - readonly outcome: 'passed' | 'failed'; - readonly results: readonly IBackwardCompatScenarioResult[]; -} - -export interface IBackwardCompatScenarioOptions { - readonly diagnosticsRoot?: string; - readonly env?: Readonly>; -} - -/** - * Verify that `listed` is *exactly* `expected` — same identities, each once. - * - * Pure and exported so the exactly-once rule can be unit tested without - * launching four builds. Returns a human-readable explanation of the first - * discrepancy, or `undefined` when the listing matches. - * - * Duplicates are reported before missing/unexpected entries because a - * duplicated identity is the specific downgrade failure this suite is built to - * catch, and reporting it as "one unexpected extra" would understate it. - */ -export function describeIdentityMismatch( - listed: readonly ILiveCompatSessionListItem[], - expected: readonly IExpectedSessionIdentity[], -): string | undefined { - const counts = new Map(); - for (const item of listed) { - counts.set(item.resource, (counts.get(item.resource) ?? 0) + 1); - } - - const duplicated = [...counts].filter(([, count]) => count > 1).map(([resource, count]) => `${resource} x${count}`); - if (duplicated.length > 0) { - return `listed the same session identity more than once: ${duplicated.join(', ')}`; - } - - const expectedResources = expected.map(entry => entry.resource); - const missing = expectedResources.filter(resource => !counts.has(resource)); - const unexpected = [...counts.keys()].filter(resource => !expectedResources.includes(resource)); - if (missing.length > 0 || unexpected.length > 0) { - return `listed sessions do not match: missing [${missing.join(', ')}], unexpected [${unexpected.join(', ')}]`; - } - - for (const entry of expected) { - if (entry.title === undefined) { - continue; - } - const actual = listed.find(item => item.resource === entry.resource)?.title; - if (actual !== entry.title) { - return `session ${entry.resource} should carry title ${JSON.stringify(entry.title)} but carries ${JSON.stringify(actual)}`; - } - } - return undefined; -} - -/** Records step outcomes and their durations in performance order. */ -class StepRecorder { - private readonly _steps: ILiveCompatStepResult[] = []; - - get steps(): readonly ILiveCompatStepResult[] { - return this._steps; - } - - async run(name: string, body: () => Promise): Promise { - const startedAt = Date.now(); - try { - const result = await body(); - this._steps.push({ name, outcome: 'passed', durationMs: Date.now() - startedAt }); - return result; - } catch (error) { - this._steps.push({ name, outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }); - throw error; - } - } - - skip(name: string, reason: string): void { - this._steps.push({ name, outcome: 'skipped', durationMs: 0, detail: reason }); - } -} - -/** - * One phase: a launched build with a connected client and its capabilities. - * - * Phases are opened and closed explicitly rather than wrapped in a callback so - * that the scenario body reads in the order the steps actually happen, and so - * that a failure mid-phase still leaves the recorder holding every step that - * ran before it. - */ -interface IPhase { - readonly server: ILiveCompatServerHandle; - readonly client: LiveCompatAhpClient; - readonly adapter: IAgentHostCapabilityAdapter; - readonly protocolVersion: string; -} - -/** - * Run one downgrade round trip: current → older → current → restart. - * - * Never throws for a scenario failure; the failure is reported in the returned - * result so that a matrix run always covers every requested pairing. - */ -export async function runBackwardCompatibilityRoundTrip( - current: IPreparedAgentHostBuild, - older: IPreparedAgentHostBuild, - options: IBackwardCompatScenarioOptions = {}, -): Promise { - const startedAt = Date.now(); - const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-backward-compat-${older.id}-`)); - const dirs = createPersistentDirectories(diagnosticsPath); - const recorder = new StepRecorder(); - - let phase: IPhase | undefined; - let currentProtocolVersion: string | undefined; - let olderProtocolVersion: string | undefined; - - /** Sessions the *provider* must be told about on every subsequent launch. */ - const seededSessions: string[] = []; - const launchFor = (build: IPreparedAgentHostBuild): ILiveCompatLaunchOptions => ({ - serverEntry: build.serverEntry, - homeDir: dirs.homeDir, - userDataDir: dirs.userDataDir, - // The mock provider keeps its session index in memory, so each process - // is told which sessions the *provider* side already knows about — the - // same recovery a real provider performs from its own on-disk state. - // The host's catalogue, which is what is under test, is never seeded and - // must be reconstructed from the shared user-data directory alone. - env: seededSessions.length === 0 - ? options.env - : { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: seededSessions.join(',') }, - }); - - const openPhase = async (build: IPreparedAgentHostBuild, clientSuffix: string): Promise => { - const server = await startLiveCompatServer(launchFor(build)); - const client = new LiveCompatAhpClient(server.port); - await client.connect(); - const initialize = await client.call('initialize', { - channel: ROOT_CHANNEL, - protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], - clientId: `backward-compat-${build.id}-${clientSuffix}`, - }, PER_CALL_TIMEOUT_MS); - return { - server, - client, - protocolVersion: initialize.protocolVersion, - adapter: createAgentHostCapabilityAdapter({ - protocolVersion: initialize.protocolVersion, - providerCapabilities: await readProviderCapabilities(client), - }), - }; - }; - - /** - * Close a phase and wait for its process to exit. - * - * Awaiting the exit is what makes the handover a handover: the next build - * reuses the same user-data directory, and a predecessor still holding it - * would turn a compatibility result into a race between two processes. - */ - const closePhase = async (): Promise => { - phase?.client.close(); - const server = phase?.server; - phase = undefined; - await stopLiveCompatServer(server); - }; - - try { - // ── phase 1 ── the newest build seeds the profile ──────────────────── - phase = await recorder.run('current-seed:launch', () => openPhase(current, 'seed')); - currentProtocolVersion = phase.protocolVersion; - const currentAdapter = phase.adapter; - - await recorder.run('current-seed:list-empty', async () => { - const listed = await phase!.client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - assertOk(describeIdentityMismatch(listed.items ?? [], []), 'a fresh profile must list no sessions'); - }); - - const sessionA = `${PROVIDER}:/backward-compat-a-${Date.now()}`; - await recorder.run('current-seed:create-session-a', async () => { - await phase!.client.call('createSession', { channel: sessionA, provider: PROVIDER }, PER_CALL_TIMEOUT_MS); - await phase!.client.call('subscribe', { channel: sessionA }, PER_CALL_TIMEOUT_MS); - seededSessions.push(sessionA); - }); - - if (currentAdapter.supportsSessionRename) { - await recorder.run('current-seed:rename-session-a', async () => { - await dispatchTitle(phase!.client, sessionA, CURRENT_SEED_TITLE, 1); - }); - } else { - recorder.skip('current-seed:rename-session-a', `negotiated protocol ${currentAdapter.protocolVersion} predates client-dispatchable session/titleChanged`); - } - - await recorder.run('current-seed:handover', async () => { - await timeout(HANDOVER_SETTLE_MS); - await closePhase(); - }); - - // ── phase 2 ── the older build opens the newer build's profile ─────── - phase = await recorder.run('older:launch', () => openPhase(older, 'downgrade')); - olderProtocolVersion = phase.protocolVersion; - const olderAdapter = phase.adapter; - /** Titles are only asserted where *both* participating builds can set them. */ - const titlesComparable = currentAdapter.supportsSessionRename && olderAdapter.supportsSessionRename; - - await recorder.run('older:list-sees-seeded-session', async () => { - const listed = await listWithRestoreRetry(phase!.client, [sessionA]); - assertOk( - describeIdentityMismatch(listed, [{ resource: sessionA, title: titlesComparable ? CURRENT_SEED_TITLE : undefined }]), - 'the older build must list the newer build\'s session exactly once, with its title', - ); - }); - - await recorder.run('older:subscribe-seeded-session', async () => { - const state = await subscribeWithRestoreRetry(phase!.client, sessionA); - if (titlesComparable) { - assertEqual(state.title, CURRENT_SEED_TITLE, 'the older build must describe the seeded session with its title'); - } - }); - - if (olderAdapter.supportsSessionRename) { - await recorder.run('older:rename-seeded-session', async () => { - await dispatchTitle(phase!.client, sessionA, OLDER_BUILD_TITLE, 2); - }); - } else { - recorder.skip('older:rename-seeded-session', `negotiated protocol ${olderAdapter.protocolVersion} predates client-dispatchable session/titleChanged`); - } - - const sessionB = `${PROVIDER}:/backward-compat-b-${Date.now()}`; - await recorder.run('older:create-session-b', async () => { - await phase!.client.call('createSession', { channel: sessionB, provider: PROVIDER }, PER_CALL_TIMEOUT_MS); - await phase!.client.call('subscribe', { channel: sessionB }, PER_CALL_TIMEOUT_MS); - seededSessions.push(sessionB); - if (olderAdapter.supportsSessionRename) { - await dispatchTitle(phase!.client, sessionB, OLDER_BUILD_SECOND_TITLE, 3); - } - }); - - await recorder.run('older:handover', async () => { - await timeout(HANDOVER_SETTLE_MS); - await closePhase(); - }); - - // ── phase 3 ── the newest build takes the profile back ─────────────── - const expected: readonly IExpectedSessionIdentity[] = [ - { resource: sessionA, title: titlesComparable ? OLDER_BUILD_TITLE : undefined }, - { resource: sessionB, title: titlesComparable ? OLDER_BUILD_SECOND_TITLE : undefined }, - ]; - - phase = await recorder.run('current-return:launch', () => openPhase(current, 'return')); - - await recorder.run('current-return:list-exactly-expected', async () => { - const listed = await listUntilExpected(phase!.client, expected); - assertOk( - describeIdentityMismatch(listed, expected), - 'the returning build must list both sessions exactly once and preserve the older build\'s titles', - ); - }); - - await recorder.run('current-return:subscribe-both', async () => { - await subscribeBoth(phase!.client, expected, titlesComparable); - }); - - // ── phase 4 ── the newest build restarts on the round-tripped profile ─ - await recorder.run('current-return:restart', async () => { - await timeout(HANDOVER_SETTLE_MS); - await closePhase(); - phase = await openPhase(current, 'restart'); - }); - - await recorder.run('current-restart:list-exactly-expected', async () => { - const listed = await listUntilExpected(phase!.client, expected); - assertOk(describeIdentityMismatch(listed, expected), 'a round-tripped profile must remain stable across a further restart'); - }); - - await recorder.run('current-restart:subscribe-both', async () => { - await subscribeBoth(phase!.client, expected, titlesComparable); - }); - - // Permanent deletion is deliberately not exercised: AHP's `disposeSession` - // releases a channel, it does not delete durable session state, and no - // command in the shared protocol surface removes a session from the - // catalogue. Asserting on delete/recreate would therefore require - // reaching past the protocol into the host's storage, which this suite - // does not do. Recorded as a skip so the coverage gap is visible in the - // result rather than implied by its absence. - recorder.skip('delete-recreate', 'AHP exposes no permanent session delete (disposeSession only releases a channel); needs a protocol affordance before it can be covered externally'); - - return result(current, older, recorder, diagnosticsPath, startedAt, currentProtocolVersion, olderProtocolVersion, undefined); - } catch (error) { - return result(current, older, recorder, diagnosticsPath, startedAt, currentProtocolVersion, olderProtocolVersion, messageOf(error)); - } finally { - phase?.client.close(); - await stopLiveCompatServer(phase?.server).catch(() => undefined); - } -} - -/** - * Run the round trip for each older checkpoint, in order. - * - * Sequential by construction: every scenario forks real Agent Host processes - * that share this machine's temp space, and serializing keeps a failure - * attributable to one pairing rather than to contention between them. - */ -export async function runBackwardCompatibilityMatrix( - current: IPreparedAgentHostBuild, - olderBuilds: readonly IPreparedAgentHostBuild[], - options: IBackwardCompatScenarioOptions = {}, -): Promise { - const startedAt = Date.now(); - const results: IBackwardCompatScenarioResult[] = []; - for (const older of olderBuilds) { - results.push(await runBackwardCompatibilityRoundTrip(current, older, options)); - } - return { - suite: 'agent-host-live-compat/backward-compatibility-round-trip', - startedAt: new Date(startedAt).toISOString(), - durationMs: Date.now() - startedAt, - outcome: results.every(entry => entry.outcome === 'passed') ? 'passed' : 'failed', - results, - }; -} - -/** Build a failed result for a checkpoint that could not even be resolved. */ -export function unresolvedBackwardCompatResult(currentBuildId: string, olderBuildId: string, reason: string): IBackwardCompatScenarioResult { - return { - scenario: 'backward-compatibility-round-trip', - currentBuild: currentBuildId, - olderBuild: olderBuildId, - outcome: 'failed', - durationMs: 0, - steps: [{ name: 'resolve-build', outcome: 'failed', durationMs: 0, detail: reason }], - diagnosticsPath: '', - error: reason, - }; -} - -async function subscribeBoth( - client: LiveCompatAhpClient, - expected: readonly IExpectedSessionIdentity[], - assertTitles: boolean, -): Promise { - for (const entry of expected) { - const state = await subscribeWithRestoreRetry(client, entry.resource); - if (assertTitles && entry.title !== undefined) { - assertEqual(state.title, entry.title, `the resubscribed session ${entry.resource} must retain its title`); - } - } -} - -/** - * Dispatch a rename and wait until it is observable. - * - * `dispatchAction` is a write-ahead notification with no response, so the - * readback is the only confirmation that the host accepted and reduced it. - */ -async function dispatchTitle(client: LiveCompatAhpClient, sessionUri: string, title: string, clientSeq: number): Promise { - client.notify('dispatchAction', { - channel: sessionUri, - clientSeq, - action: { type: 'session/titleChanged', title }, - }); - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); - if (subscribed.snapshot?.state?.title === title) { - return; - } - await timeout(RESTORE_RETRY_DELAY_MS); - } - throw new Error(`the dispatched title ${JSON.stringify(title)} never became observable on ${sessionUri}`); -} - -/** - * List sessions, retrying until every awaited identity has appeared. - * - * A build restoring a profile populates its catalogue concurrently with - * accepting connections, so an immediate `listSessions` can legitimately answer - * with a partial set. Retrying is part of the contract a client must implement; - * the budget is bounded so a genuinely lost session still fails, and the last - * observed listing is returned so the caller's assertion reports what was - * actually there rather than a timeout. - */ -async function listWithRestoreRetry(client: LiveCompatAhpClient, awaited: readonly string[]): Promise { - let items: readonly ILiveCompatSessionListItem[] = []; - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - const listed = await client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - items = listed.items ?? []; - if (awaited.every(resource => items.some(item => item.resource === resource))) { - return items; - } - await timeout(RESTORE_RETRY_DELAY_MS); - } - return items; -} - -/** - * List until the whole expectation holds, not merely until the identities exist. - * - * A returning build serves `listSessions` from its central catalogue, which an - * older build cannot write; the newer build repairs those rows in a background - * reconciliation pass that is *scheduled*, not awaited by the protocol. The - * contract a client sees is therefore eventual, so the assertion polls the - * observable AHP surface until it converges instead of sleeping for a fixed - * period. The budget is bounded, and the last listing is returned so a genuine - * failure is reported as the mismatch it is rather than as a timeout. - */ -async function listUntilExpected( - client: LiveCompatAhpClient, - expected: readonly IExpectedSessionIdentity[], -): Promise { - let items: readonly ILiveCompatSessionListItem[] = []; - for (let attempt = 0; attempt < CONVERGENCE_ATTEMPTS; attempt++) { - const listed = await client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - items = listed.items ?? []; - if (describeIdentityMismatch(items, expected) === undefined) { - return items; - } - await timeout(RESTORE_RETRY_DELAY_MS); - } - return items; -} - -/** Subscribe to a restored session, tolerating the transient describe window. */ -async function subscribeWithRestoreRetry(client: LiveCompatAhpClient, sessionUri: string): Promise<{ title?: string }> { - let lastError: unknown; - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - try { - const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); - return subscribed.snapshot?.state ?? {}; - } catch (error) { - lastError = error; - await timeout(RESTORE_RETRY_DELAY_MS); - } - } - throw new Error(`could not resubscribe to ${sessionUri} within ${RESTORE_ATTEMPTS} attempts: ${messageOf(lastError)}`); -} - -/** Read provider capabilities off the root snapshot, as any client would. */ -async function readProviderCapabilities(client: LiveCompatAhpClient): Promise> { - const root = await client.call('subscribe', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - const capabilities = new Map(); - for (const agent of root.snapshot?.state?.agents ?? []) { - capabilities.set(agent.provider, agent.capabilities ?? {}); - } - return capabilities; -} - -function createPersistentDirectories(root: string): { homeDir: string; userDataDir: string } { - const homeDir = join(root, 'home'); - const userDataDir = join(root, 'user-data'); - mkdirSync(homeDir, { recursive: true }); - mkdirSync(join(homeDir, '.codex'), { recursive: true }); - mkdirSync(userDataDir, { recursive: true }); - mkdirSync(join(root, 'workspace'), { recursive: true }); - return { homeDir, userDataDir }; -} - -function result( - current: IPreparedAgentHostBuild, - older: IPreparedAgentHostBuild, - recorder: StepRecorder, - diagnosticsPath: string, - startedAt: number, - currentProtocolVersion: string | undefined, - olderProtocolVersion: string | undefined, - error: string | undefined, -): IBackwardCompatScenarioResult { - return { - scenario: 'backward-compatibility-round-trip', - currentBuild: current.id, - olderBuild: older.id, - olderBuildDescription: older.description, - outcome: error === undefined ? 'passed' : 'failed', - durationMs: Date.now() - startedAt, - currentProtocolVersion, - olderProtocolVersion, - steps: recorder.steps, - diagnosticsPath, - ...(error === undefined ? {} : { error }), - }; -} - -function assertOk(mismatch: string | undefined, what: string): void { - if (mismatch !== undefined) { - throw new Error(`${what}: ${mismatch}`); - } -} - -function assertEqual(actual: T, expected: T, what: string): void { - if (actual !== expected) { - throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); - } -} - -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.test.ts deleted file mode 100644 index 22eed6e82f0061..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Unit coverage for the forward-migration matrix's *composition* rules. - * - * What this file deliberately does not do is launch a build. The scenario body - * is exercised for real by the live run (`--run-forward-migrations`), and - * duplicating that here would trade a twelve-minute honest signal for a fast - * dishonest one. What is worth pinning cheaply is the surrounding contract: the - * pair list, and the promise that an unresolvable checkpoint is reported as a - * failed row carrying the resolver's explanation rather than skipped. - */ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; -import { FORWARD_MIGRATION_SOURCES, runForwardMigrations } from './runForwardMigrationMatrix.js'; - -suite('Agent Host forward-migration matrix', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('every historical checkpoint upgrades to the current working tree', () => { - assert.deepStrictEqual( - [...FORWARD_MIGRATION_SOURCES], - [AgentHostBuildId.Legacy, AgentHostBuildId.Predecessor, AgentHostBuildId.Intermediate], - ); - }); - - test('an unpreparable checkpoint is a reported failure, never a silent skip', async () => { - const summary = await runForwardMigrations({ - // A repository root with no prepared cache: resolution must fail for - // every pair, which is precisely the condition under test. - repoRoot: '/nonexistent-agent-host-live-compat-root', - cacheRoot: '/nonexistent-agent-host-live-compat-cache', - resolveCommit: () => undefined, - includeMultiSession: false, - }); - - assert.deepStrictEqual( - { - outcome: summary.outcome, - rows: summary.results.map(result => ({ - build: result.build, - outcome: result.outcome, - steps: result.steps.map(step => step.name), - hasExplanation: (result.error ?? '').length > 0, - })), - }, - { - outcome: 'failed', - rows: [ - { build: 'legacy->current', outcome: 'failed', steps: ['resolve-build'], hasExplanation: true }, - { build: 'predecessor->current', outcome: 'failed', steps: ['resolve-build'], hasExplanation: true }, - { build: 'intermediate->current', outcome: 'failed', steps: ['resolve-build'], hasExplanation: true }, - ], - }, - ); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.ts deleted file mode 100644 index 2078c829b46f26..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/forwardMigrationMatrix.ts +++ /dev/null @@ -1,624 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * The forward-migration matrix: an older build seeds a profile, the current - * build inherits it. - * - * This is the scenario the whole live-compat apparatus exists for. The - * same-build restart baseline established that each checkpoint round-trips its - * *own* profile; that result is what makes a failure here attributable. If a - * build can reopen what it wrote, but the current build cannot reopen what that - * build wrote, the difference is a forward-migration defect and nothing else. - * - * Shape of a run, all of it over AHP against real forked server processes: - * - * ```text - * phase 1 — source build phase 2 — current build phase 3 — current build - * (legacy | predecessor | (same home + user-data) (same home + user-data) - * intermediate) - * initialize initialize initialize - * list (empty) list ─┐ list ─┐ - * create session(s) + cwd subscribe ├ restored subscribe ├ identical - * rename session(s) assert ─┘ assert ─┘ - * stop cleanly (idempotent) - * ``` - * - * Four properties are load-bearing, and each is a rule rather than an - * implementation detail: - * - * - **One profile, three launches.** The home, user-data and workspace - * directories are created once and handed unchanged to every phase. The - * inheritance *is* the subject; a fresh profile in phase 2 would make every - * assertion vacuous. - * - **Clean handover.** The source build is stopped and awaited before the - * current build is launched, so phase 2 reads a profile that was closed - * rather than one still being written. - * - **External only.** Nothing here reads the host database, imports host - * internals, or inspects logs for assertions. Every claim is a readback over - * AHP, which is the only surface a real client has. - * - **Contract differences come from the wire.** The source builds negotiate - * older protocol versions (0.8 and 1.0 are both in the matrix today) and may - * not carry every field. Those differences are resolved through the - * capability adapter and through what the *source build itself was observed - * to report* — never from the checkpoint id. A field the source never - * reported is not asserted after migration, because its absence would be a - * property of the seed, not of the migration. Where a field turns out to be - * unstable for reasons unrelated to migration, it is recorded as an explicit - * skip with evidence (see {@link WORKING_DIRECTORY_SKIP_REASON}) rather than - * asserted or quietly dropped. - * - * The scenario runs against the scripted mock provider and never contacts a - * model: the subject is the host's own persistence and migration, so a - * provider that needs replay fixtures recorded per checkpoint would only add a - * second, unrelated way to fail. - */ - -import { mkdirSync, mkdtempSync } from 'fs'; -import { tmpdir } from 'os'; -import { timeout } from '../../../../../../base/common/async.js'; -import { join } from '../../../../../../base/common/path.js'; -import { LiveCompatAhpClient } from './agentHostLiveCompatClient.js'; -import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; -import { - createAgentHostCapabilityAdapter, - LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, - type IAgentHostCapabilityAdapter, -} from './agentHostLiveCompatCapabilities.js'; -import { startLiveCompatServer, stopLiveCompatServer, type ILiveCompatLaunchOptions, type ILiveCompatServerHandle } from './agentHostLiveCompatServer.js'; -import type { - AgentProviderCapabilities, - ILiveCompatInitializeResult, - ILiveCompatSessionList, - ILiveCompatSessionListItem, - ILiveCompatSubscribeResult, -} from './agentHostLiveCompatProtocol.js'; -import type { ILiveCompatScenarioResult, ILiveCompatStepResult } from './sameBuildRestartBaseline.js'; - -/** Root channel URI. A constant of the protocol, stable across every build. */ -const ROOT_CHANNEL = 'ahp-root://'; -/** Provider the matrix drives; see the file header for why it is the mock. */ -const PROVIDER = 'mock'; -const PER_CALL_TIMEOUT_MS = 30_000; - -/** - * A restored session is not necessarily describable the instant the host is - * accepting connections: the provider is re-registered and the catalogue - * re-read concurrently with the socket opening. Retrying is part of the - * contract a client implements, not a workaround — but the budget is bounded - * so a genuinely lost session still fails. - */ -const RESTORE_ATTEMPTS = 20; -const RESTORE_RETRY_DELAY_MS = 500; - -/** - * Time allowed for the seed's catalogue writes to reach disk before the source - * build is stopped. - * - * The host exposes no durability acknowledgment for the catalogue write, and - * does not await it during shutdown, so a bounded settle window is currently - * the only way to distinguish "the migration lost it" from "it was never - * written". Making this unnecessary is a host-side change (an observable - * durability ack), not a scenario change. - */ -const SEED_SETTLE_MS = 1_000; - -/** - * Extra narrowing of the wire shapes, on top of the shared protocol module. - * - * `workingDirectories` is asserted only by this matrix — the restart baseline - * has no reason to look at it — so it is declared here rather than widened in - * the shared module. Widening that module is a statement that *every* scenario - * depends on the field; this is the narrower, truer statement. - */ -interface IForwardMigrationSessionItem extends ILiveCompatSessionListItem { - readonly workingDirectories?: readonly string[]; -} - -interface IForwardMigrationSessionList extends ILiveCompatSessionList { - readonly items?: readonly IForwardMigrationSessionItem[]; -} - -interface IForwardMigrationChannelState { - readonly title?: string; - readonly workingDirectories?: readonly string[]; -} - -interface IForwardMigrationSubscribeResult extends ILiveCompatSubscribeResult { - readonly snapshot?: { readonly state?: IForwardMigrationChannelState }; -} - -/** - * Why working directories are seeded but not asserted after the handover. - * - * A session is created here *with* a working directory, and the source build - * reports it back — so the seed is real. But the scripted mock provider only - * ever reports `workingDirectories` from its creation path; its re-description - * paths (`listSessions`, `getSessionMetadata`) omit the field entirely. Once a - * restarted host re-describes a session from the provider, the field is - * therefore absent at the source, and the host's catalogue follows. - * - * This was measured rather than assumed. Running this same scenario with the - * working tree as *both* source and target — an upgrade that migrates nothing — - * reproduces it exactly: the first reopen still carries the directories, and - * the second, after the provider has re-described the session, does not. A - * defect that reproduces with migration removed is not a migration defect. - * - * So asserting on it here would report a property of the reference provider as - * a forward-compatibility failure on every pair in the matrix, which is worse - * than not covering it: it would make the matrix loud and wrong. The step is - * recorded as an explicit skip carrying this reason instead, keeping the - * coverage honest rather than silently narrower than it looks. Closing it needs - * a provider that re-describes working directories (a change to shared - * `mockAgent.ts`, out of scope here) or a bundled provider, not a change to - * this scenario. - */ -const WORKING_DIRECTORY_SKIP_REASON = - 'the mock provider reports workingDirectories only on creation, never on re-description; ' - + 'reproduced with current->current, so it is a provider limitation rather than a migration defect'; - -/** - * What phase 1 durably established about one session, as *observed over AHP - * from the source build itself*. - * - * Recording the observation rather than the intent is what keeps the matrix - * honest across contract evolution. If a source build never reported a title, - * phase 2 does not assert one: the absence would say something about the seed, - * not about the migration under test. - */ -interface ISeededSession { - readonly resource: string; - /** Title the source build reported back, if it reported one at all. */ - readonly title: string | undefined; - /** - * Working directories the source build reported back, if any. Recorded for - * the diagnostics record only; see {@link WORKING_DIRECTORY_SKIP_REASON}. - */ - readonly workingDirectories: readonly string[] | undefined; -} - -export interface IForwardMigrationOptions { - /** Root under which the per-scenario diagnostics directory is created. */ - readonly diagnosticsRoot?: string; - /** Extra environment for every launch. */ - readonly env?: Readonly>; - /** - * How many sessions to seed. One is the canonical case; a multi-session run - * additionally exercises that migration preserves a *set* rather than - * merely a single row, and that identities are not conflated. - */ - readonly sessionCount?: number; - /** Distinguishes result rows when a pair is run at several session counts. */ - readonly scenarioSuffix?: string; -} - -/** Aggregate outcome of one forward-migration run. */ -export interface IForwardMigrationSummary { - readonly suite: string; - readonly startedAt: string; - readonly durationMs: number; - readonly outcome: 'passed' | 'failed'; - readonly results: readonly ILiveCompatScenarioResult[]; -} - -/** Records step outcomes and their durations in performance order. */ -class StepRecorder { - private readonly _steps: ILiveCompatStepResult[] = []; - - get steps(): readonly ILiveCompatStepResult[] { - return this._steps; - } - - async run(name: string, body: () => Promise): Promise { - const startedAt = Date.now(); - try { - const result = await body(); - this._steps.push({ name, outcome: 'passed', durationMs: Date.now() - startedAt }); - return result; - } catch (error) { - this._steps.push({ name, outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }); - throw error; - } - } - - skip(name: string, reason: string): void { - this._steps.push({ name, outcome: 'skipped', durationMs: 0, detail: reason }); - } -} - -/** - * Run one forward-migration scenario: `source` seeds a profile, `target` - * inherits it, and `target` is then restarted to show the result is stable. - * - * Never throws for a scenario failure. A failed pair is data the caller needs - * alongside the pairs that passed, so the failure is reported in the returned - * result; only a defect in the runner itself propagates. - */ -export async function runForwardMigrationScenario( - source: IPreparedAgentHostBuild, - target: IPreparedAgentHostBuild, - options: IForwardMigrationOptions = {}, -): Promise { - const sessionCount = options.sessionCount ?? 1; - const scenario = `forward-migration${options.scenarioSuffix ? `/${options.scenarioSuffix}` : ''}`; - const startedAt = Date.now(); - const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-forward-${source.id}-to-${target.id}-`)); - const dirs = createSharedProfile(diagnosticsPath); - const recorder = new StepRecorder(); - let server: ILiveCompatServerHandle | undefined; - let client: LiveCompatAhpClient | undefined; - /** The negotiated version of the *target*: the build the claim is about. */ - let protocolVersion: string | undefined; - - const launchOn = (build: IPreparedAgentHostBuild, env?: Readonly>): ILiveCompatLaunchOptions => ({ - serverEntry: build.serverEntry, - homeDir: dirs.homeDir, - userDataDir: dirs.userDataDir, - env: { ...options.env, ...env }, - }); - - try { - // ── phase 1: the source build seeds the profile ────────────────────── - server = await recorder.run('launch-source', () => startLiveCompatServer(launchOn(source))); - client = await connect(server); - - const sourceAdapter = await recorder.run('initialize-source', async () => { - const initialize = await client!.call('initialize', { - channel: ROOT_CHANNEL, - protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], - clientId: `forward-${source.id}-seed`, - }, PER_CALL_TIMEOUT_MS); - return createAgentHostCapabilityAdapter({ - protocolVersion: initialize.protocolVersion, - providerCapabilities: await readProviderCapabilities(client!), - }); - }); - - await recorder.run('list-empty-source', async () => { - const listed = await client!.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - assertEqual(listed.items?.length ?? 0, 0, 'a fresh profile must list no sessions'); - }); - - const seeded = await seedSessions(recorder, client, sourceAdapter, source, dirs.workspaceDir, sessionCount); - - await recorder.run('stop-source', async () => { - client!.close(); - client = undefined; - await stopLiveCompatServer(server); - server = undefined; - }); - - // ── phase 2: the target build inherits the profile ─────────────────── - // The mock provider keeps its session index in memory, so the new - // process is told which sessions the *provider* side already knows - // about — mirroring what a real provider recovers from its own on-disk - // state. The host's persistence, which is what is under test, is not - // seeded and must be reconstructed from the retained user-data - // directory alone. - const mockSeed = { VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: seeded.map(session => session.resource).join(',') }; - - protocolVersion = await recorder.run('launch-target', async () => { - server = await startLiveCompatServer(launchOn(target, mockSeed)); - client = await connect(server); - const initialize = await client.call('initialize', { - channel: ROOT_CHANNEL, - protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], - clientId: `forward-${source.id}-to-${target.id}-verify`, - }, PER_CALL_TIMEOUT_MS); - return initialize.protocolVersion; - }); - - const migrated = await recorder.run('list-migrated', () => assertListMatchesSeed(client!, seeded)); - await recorder.run('subscribe-migrated', () => assertSubscribeMatchesSeed(client!, seeded)); - recorder.skip('working-directories-preserved', WORKING_DIRECTORY_SKIP_REASON); - - // ── phase 3: the same target build, restarted ──────────────────────── - // Migration must be a fixed point. A run that converts on first open - // but keeps converting — or worse, converges to something different — - // would pass phase 2 and still be broken in the only way users meet it: - // the second launch. - await recorder.run('restart-target', async () => { - client!.close(); - client = undefined; - await stopLiveCompatServer(server); - server = undefined; - server = await startLiveCompatServer(launchOn(target, mockSeed)); - client = await connect(server); - await client.call('initialize', { - channel: ROOT_CHANNEL, - protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], - clientId: `forward-${source.id}-to-${target.id}-idempotent`, - }, PER_CALL_TIMEOUT_MS); - }); - - await recorder.run('list-idempotent', async () => { - const again = await assertListMatchesSeed(client!, seeded); - // Compared against phase 2's readback rather than against the seed - // alone: that is what makes this an idempotence claim instead of a - // second, weaker restore claim. - assertEqual( - JSON.stringify(again), - JSON.stringify(migrated), - 'a second launch of the migrated profile must produce an identical listing', - ); - }); - await recorder.run('subscribe-idempotent', () => assertSubscribeMatchesSeed(client!, seeded)); - - return result(scenario, source, target, recorder, diagnosticsPath, startedAt, protocolVersion, undefined); - } catch (error) { - return result(scenario, source, target, recorder, diagnosticsPath, startedAt, protocolVersion, messageOf(error)); - } finally { - client?.close(); - await stopLiveCompatServer(server).catch(() => undefined); - } -} - -/** - * Run the forward-migration matrix: every requested source build, upgraded to - * the same target, in order. - * - * Builds run sequentially. Each scenario forks two real Agent Hosts from - * different compiled trees sharing this machine's temp space; serializing keeps - * a failure attributable to one pair rather than to contention between them. - */ -export async function runForwardMigrationMatrix( - pairs: readonly { readonly source: IPreparedAgentHostBuild; readonly target: IPreparedAgentHostBuild; readonly options?: IForwardMigrationOptions }[], -): Promise { - const startedAt = Date.now(); - const results: ILiveCompatScenarioResult[] = []; - for (const pair of pairs) { - results.push(await runForwardMigrationScenario(pair.source, pair.target, pair.options)); - } - return { - suite: 'agent-host-live-compat/forward-migration', - startedAt: new Date(startedAt).toISOString(), - durationMs: Date.now() - startedAt, - outcome: results.every(entry => entry.outcome === 'passed') ? 'passed' : 'failed', - results, - }; -} - -/** - * Create sessions on the source build and record what that build reports back. - * - * The readback is the point. Everything phase 2 asserts is drawn from what the - * source build itself was seen to hold, so the matrix tests migration rather - * than the union of migration and whatever the seeding build happened to - * support. - */ -async function seedSessions( - recorder: StepRecorder, - client: LiveCompatAhpClient | undefined, - adapter: IAgentHostCapabilityAdapter, - source: IPreparedAgentHostBuild, - workspaceDir: string, - sessionCount: number, -): Promise { - const created = await recorder.run('create-sessions', async () => { - const uris: string[] = []; - for (let index = 0; index < sessionCount; index++) { - const uri = `${PROVIDER}:/forward-${source.id}-${Date.now()}-${index}`; - await client!.call('createSession', { - channel: uri, - provider: PROVIDER, - workingDirectories: [uriForDirectory(workspaceDir)], - }, PER_CALL_TIMEOUT_MS); - await client!.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); - uris.push(uri); - } - return uris; - }); - - if (!adapter.supportsSessionRename) { - recorder.skip('rename-sessions', `negotiated protocol ${adapter.protocolVersion} predates client-dispatchable session/titleChanged`); - } else { - await recorder.run('rename-sessions', async () => { - for (const [index, uri] of created.entries()) { - // `dispatchAction` is a write-ahead notification, so the - // readback below is what confirms the host accepted it. - client!.notify('dispatchAction', { - channel: uri, - clientSeq: index + 1, - action: { type: 'session/titleChanged', title: titleFor(source, index) }, - }); - } - for (const [index, uri] of created.entries()) { - const state = await pollForTitle(client!, uri, titleFor(source, index)); - assertEqual(state.title, titleFor(source, index), `the dispatched title for ${uri} must be observable before the handover`); - } - }); - } - - // Give the catalogue writes a chance to land before the process is stopped; - // see the note on SEED_SETTLE_MS for why an explicit window is the honest - // instrument here rather than a retry that would hide the distinction. - await recorder.run('settle-seed', () => timeout(SEED_SETTLE_MS)); - - return recorder.run('read-seed', async () => { - const listed = await client!.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - assertEqual(listed.items?.length ?? 0, created.length, 'the source build must list exactly the sessions it just created'); - return created.map(resource => { - const item = listed.items?.find(candidate => candidate.resource === resource); - assertEqual(item?.resource, resource, `the source build must list the session it created at ${resource}`); - return { - resource, - title: item?.title, - workingDirectories: item?.workingDirectories === undefined ? undefined : [...item.workingDirectories].sort(), - } satisfies ISeededSession; - }); - }); -} - -/** - * The durable facts a listing is compared on across launches. - * - * Working directories are excluded deliberately, and this is the one place - * where that exclusion is load-bearing rather than merely unasserted: per - * {@link WORKING_DIRECTORY_SKIP_REASON} the field is present on the first - * reopen and absent on the second, so including it would make the idempotence - * comparison fail on a provider artifact and hide any real instability in the - * fields that do carry a migration claim. - */ -interface IObservedSession { - readonly resource: string; - readonly title: string | undefined; -} - -/** - * Assert the migrated profile lists exactly the seeded sessions, and return - * the normalized listing so a later launch can be compared against it. - */ -async function assertListMatchesSeed(client: LiveCompatAhpClient, seeded: readonly ISeededSession[]): Promise { - let last: readonly IForwardMigrationSessionItem[] = []; - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - const listed = await client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - last = listed.items ?? []; - if (last.length === seeded.length) { - break; - } - // The catalogue is re-read concurrently with the socket opening, so a - // short listing is transient early and only meaningful once the budget - // is spent. - await timeout(RESTORE_RETRY_DELAY_MS); - } - - assertEqual(last.length, seeded.length, 'the migrated profile must list exactly the seeded sessions, and no others'); - const observed: IObservedSession[] = []; - for (const session of seeded) { - const item = last.find(candidate => candidate.resource === session.resource); - assertEqual(item?.resource, session.resource, `the session ${session.resource} must survive the upgrade with its identity intact`); - assertEqual(item?.provider ?? PROVIDER, PROVIDER, `the session ${session.resource} must keep its provider`); - if (session.title !== undefined) { - assertEqual(item?.title, session.title, `the session ${session.resource} must keep the title the source build held`); - } - observed.push({ resource: session.resource, title: item?.title }); - } - return observed; -} - -/** Assert each seeded session is individually describable after migration. */ -async function assertSubscribeMatchesSeed(client: LiveCompatAhpClient, seeded: readonly ISeededSession[]): Promise { - for (const session of seeded) { - const state = await subscribeWithRestoreRetry(client, session.resource); - if (session.title !== undefined) { - assertEqual(state.title, session.title, `the resubscribed session ${session.resource} must keep its title`); - } - } -} - -/** - * Create the profile every phase shares. - * - * Created once, deliberately: the inheritance across launches is the subject of - * the scenario, so these paths are computed here and never re-derived per - * phase, where a divergence would silently turn the run into three unrelated - * fresh-profile runs that all pass. - */ -function createSharedProfile(root: string): { homeDir: string; userDataDir: string; workspaceDir: string } { - const homeDir = join(root, 'home'); - const userDataDir = join(root, 'user-data'); - const workspaceDir = join(root, 'workspace'); - mkdirSync(homeDir, { recursive: true }); - mkdirSync(join(homeDir, '.codex'), { recursive: true }); - mkdirSync(userDataDir, { recursive: true }); - mkdirSync(workspaceDir, { recursive: true }); - return { homeDir, userDataDir, workspaceDir }; -} - -/** - * A `file:` URI for an absolute directory path. - * - * Hand-built rather than taken from `URI.file`: this module is loaded by a - * plain `node` runner as well as by Mocha, and the paths involved are temp - * directories the scenario created itself, so the general-purpose encoder's - * behavior is not needed. Encoding is still applied so a temp root containing - * spaces cannot produce a malformed URI. - */ -function uriForDirectory(path: string): string { - const normalized = path.replace(/\\/g, '/'); - const withLeadingSlash = normalized.startsWith('/') ? normalized : `/${normalized}`; - return `file://${withLeadingSlash.split('/').map(encodeURIComponent).join('/')}`; -} - -function titleFor(source: IPreparedAgentHostBuild, index: number): string { - return `Forward Migration ${source.id} #${index + 1}`; -} - -async function connect(server: ILiveCompatServerHandle): Promise { - const client = new LiveCompatAhpClient(server.port); - await client.connect(); - return client; -} - -/** Read provider capabilities off the root snapshot, as any client would. */ -async function readProviderCapabilities(client: LiveCompatAhpClient): Promise> { - const root = await client.call('subscribe', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - const capabilities = new Map(); - for (const agent of root.snapshot?.state?.agents ?? []) { - capabilities.set(agent.provider, agent.capabilities ?? {}); - } - return capabilities; -} - -async function pollForTitle(client: LiveCompatAhpClient, sessionUri: string, expected: string): Promise { - let state: IForwardMigrationChannelState = {}; - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); - state = subscribed.snapshot?.state ?? {}; - if (state.title === expected) { - return state; - } - await timeout(RESTORE_RETRY_DELAY_MS); - } - return state; -} - -/** Subscribe to a migrated session, tolerating the transient describe window. */ -async function subscribeWithRestoreRetry(client: LiveCompatAhpClient, sessionUri: string): Promise { - let lastError: unknown; - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - try { - const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); - return subscribed.snapshot?.state ?? {}; - } catch (error) { - lastError = error; - await timeout(RESTORE_RETRY_DELAY_MS); - } - } - throw new Error(`could not resubscribe to ${sessionUri} within ${RESTORE_ATTEMPTS} attempts: ${messageOf(lastError)}`); -} - -function result( - scenario: string, - source: IPreparedAgentHostBuild, - target: IPreparedAgentHostBuild, - recorder: StepRecorder, - diagnosticsPath: string, - startedAt: number, - protocolVersion: string | undefined, - error: string | undefined, -): ILiveCompatScenarioResult { - return { - scenario, - build: `${source.id}->${target.id}`, - buildDescription: `${source.description ?? source.id} → ${target.description ?? target.id}`, - outcome: error === undefined ? 'passed' : 'failed', - durationMs: Date.now() - startedAt, - protocolVersion, - steps: recorder.steps, - diagnosticsPath, - ...(error === undefined ? {} : { error }), - }; -} - -function assertEqual(actual: T, expected: T, what: string): void { - if (actual !== expected) { - throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); - } -} - -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/liveCompatRunner.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/liveCompatRunner.test.ts deleted file mode 100644 index 5d8aa379591b60..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/liveCompatRunner.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Contract tests for the live-compat CLI, driven as a black box. - * - * The CLI is the only thing a developer or a CI job ever touches, so the - * properties worth pinning here are the ones a caller depends on and cannot see - * from the matrices themselves: - * - * - `--check` is **non-destructive**: it reports readiness and never prepares, - * compiles, checks out or writes anything. - * - An unprepared checkpoint is a **reported failure with a nonzero exit**, - * never a skip — so a run covering two of three upgrades can never be - * mistaken for one covering three. - * - Summaries land at **stable paths**, which is what makes CI able to collect - * evidence by name rather than by glob-and-hope. - * - * These run the script against a deliberately empty cache root, so no build is - * ever launched and the suite stays fast. The scenario bodies are exercised for - * real by `npm run agent-host-live-compat-all`. - */ - -import assert from 'assert'; -import { spawnSync } from 'child_process'; -import { existsSync, mkdtempSync, readdirSync, readFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from '../../../../../../base/common/path.js'; -import { fileURLToPath } from 'url'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; - -const repoRoot = fileURLToPath(new URL('../../../../../../../../', import.meta.url)); -const cliPath = join(repoRoot, 'scripts', 'test-agent-host-live-compat.ts'); - -interface ICliResult { - readonly status: number; - readonly stdout: string; - readonly stderr: string; -} - -/** - * Run the CLI against an empty cache root so no historical build resolves. - * - * `ELECTRON_RUN_AS_NODE` matters: this suite executes inside Electron, whose - * `execPath` would otherwise boot a renderer instead of running the script. - */ -function runCli(args: readonly string[], cacheRoot: string): ICliResult { - const result = spawnSync(process.execPath, [cliPath, ...args], { - cwd: repoRoot, - encoding: 'utf8', - env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', AGENT_HOST_LIVE_COMPAT_CACHE: cacheRoot }, - }); - if (result.error) { - throw result.error; - } - return { status: result.status ?? -1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; -} - -suite('Agent Host live-compat runner', function () { - - // Every test forks the real CLI, which costs a process start each time. - this.timeout(60_000); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('--check reports unprepared builds without preparing anything', () => { - const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-check-')); - const result = runCli(['--check'], cacheRoot); - - assert.deepStrictEqual( - { - status: result.status, - namesMissingBuilds: /Not ready: .*legacy/.test(result.stderr), - // Non-destructive: nothing was materialized under the cache root. - cacheRootUntouched: readdirSync(cacheRoot).length === 0, - }, - { status: 1, namesMissingBuilds: true, cacheRootUntouched: true }, - ); - }); - - test('an unprepared checkpoint fails the run and names the prepare command', () => { - const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-run-')); - const outputDir = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-out-')); - const result = runCli(['--run-backward', '--output-dir', outputDir], cacheRoot); - const summaryPath = join(outputDir, 'backward-compatibility.json'); - const summary = JSON.parse(readFileSync(summaryPath, 'utf8')) as { - outcome: string; - results: readonly { outcome: string; error?: string }[]; - }; - - assert.deepStrictEqual( - { - status: result.status, - summaryWritten: existsSync(summaryPath), - outcome: summary.outcome, - // Every pair is present as a failed row, never absent. - rowOutcomes: summary.results.map(entry => entry.outcome), - everyRowExplains: summary.results.every(entry => (entry.error ?? '').includes('--prepare')), - }, - { - status: 1, - summaryWritten: true, - outcome: 'failed', - rowOutcomes: ['failed', 'failed', 'failed'], - everyRowExplains: true, - }, - ); - }); - - test('a multi-matrix run writes one stable summary per matrix plus an aggregate', () => { - const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-all-')); - const outputDir = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-all-out-')); - // Forward and backward both need a historical checkpoint on at least one - // end, so an empty cache fails every pair at resolution and no build is - // ever launched — which is what keeps this a unit test. Baselines and - // recovery are excluded here precisely because they *would* launch the - // working tree; the live run covers them. - const result = runCli(['--run-forward', '--run-backward', '--output-dir', outputDir], cacheRoot); - const run = JSON.parse(readFileSync(join(outputDir, 'run.json'), 'utf8')) as { - outcome: string; - subset: string; - matrices: readonly { id: string }[]; - }; - - assert.deepStrictEqual( - { - status: result.status, - files: readdirSync(outputDir).filter(name => name.endsWith('.json')).sort(), - outcome: run.outcome, - subset: run.subset, - matrices: run.matrices.map(entry => entry.id), - }, - { - status: 1, - files: ['backward-compatibility.json', 'forward-migration.json', 'run.json'], - outcome: 'failed', - subset: 'full', - matrices: ['forward', 'backward'], - }, - ); - }); - - /** - * A checkpoint can be absent for reasons that are nobody's mistake: a - * shallow clone, a fork, or a checkpoint pinned to a feature-branch-only - * commit that the default branch cannot reach. This file runs in the - * ordinary unit job, which is shallow, so the CLI degrading to its own - * actionable result rather than a raw `git rev-parse` failure is what keeps - * that job green — and is a property worth pinning rather than assuming. - */ - test('an unresolvable checkpoint ref degrades to an actionable result, not a git error', () => { - const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-unresolvable-')); - const listing = runCli(['--list'], cacheRoot); - const check = runCli(['--check'], cacheRoot); - - assert.deepStrictEqual( - { - // `--list` is a status report; an absent checkpoint is data, not - // a crash, so it stays successful. - listStatus: listing.status, - // `--check` reports unreadiness by exiting nonzero. - checkStatus: check.status, - // Neither leaks git's own vocabulary for a missing revision. - mentionsGitFailure: /unknown revision|ambiguous argument|fatal:/.test(listing.stdout + listing.stderr + check.stdout + check.stderr), - // Every historical checkpoint is accounted for by name. - namesEveryCheckpoint: ['legacy', 'predecessor', 'intermediate'].every(id => listing.stdout.includes(id)), - }, - { listStatus: 0, checkStatus: 1, mentionsGitFailure: false, namesEveryCheckpoint: true }, - ); - }); - - test('--pr requires a run command and --json requires a single matrix', () => { - const cacheRoot = mkdtempSync(join(tmpdir(), 'agent-host-live-compat-args-')); - - assert.deepStrictEqual( - { - prAlone: runCli(['--pr'], cacheRoot).stderr.includes('combine it with'), - jsonWithAll: runCli(['--run-all', '--json', 'x.json'], cacheRoot).stderr.includes('single matrix'), - buildWithoutBaselines: runCli(['--run-forward', '--build', 'legacy'], cacheRoot).stderr.includes('--run-baselines'), - }, - { prAlone: true, jsonWithAll: true, buildWithoutBaselines: true }, - ); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.test.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.test.ts deleted file mode 100644 index 078863880f53e7..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Focused tests for the decision logic of the process-recovery matrix. - * - * The scenarios themselves fork real Agent Hosts and kill them, so they are run - * by `runRecoveryMatrix` rather than by Mocha. What *is* unit-testable — and is - * the part a wrong answer would silently corrupt every live result with — is - * the classifier: it decides which post-crash observations are admissible - * durability gaps and which are recovery defects. - * - * That line is worth testing precisely because a live run is not guaranteed to - * produce every observation shape. A machine with a fast disk may never once - * exhibit a lost rename, so the handling of that shape would otherwise ship - * unexercised and be discovered only by a CI machine that is slower. - */ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { - classifyRecovery, - isRecoveryDefect, - RECOVERY_BOUNDARIES, - RECOVERY_INTEGRATION_PROPOSALS, - RecoveryClassification, - type IRecoveryObservation, - type IRecoveryScenarioResult, -} from './recoveryMatrix.js'; -import { tallyClassifications } from './runRecoveryMatrix.js'; - -const TITLES = { afterMutation: 'Renamed' } as const; - -function classify(observation: IRecoveryObservation): RecoveryClassification { - return classifyRecovery(observation, TITLES); -} - -function scenarioResult(classifications: readonly RecoveryClassification[]): IRecoveryScenarioResult { - return { - scenario: 'test', - build: 'current', - outcome: 'passed', - durationMs: 0, - steps: [], - classifications, - diagnosticsPath: '', - }; -} - -suite('Agent Host recovery matrix', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('a surviving session is admissible whether or not the rename survived with it', () => { - assert.deepStrictEqual( - [ - // Durable: the mutation reached disk before the kill. - classify({ listedCount: 1, listedTitle: 'Renamed', describedTitle: 'Renamed' }), - // The known catalogue-write gap: readable before the kill, gone after. - classify({ listedCount: 1, listedTitle: 'Untitled', describedTitle: 'Untitled' }), - // Surfaces may restore at different rates; either carrying the new - // title is enough to call the mutation durable. - classify({ listedCount: 1, listedTitle: 'Renamed', describedTitle: 'Untitled' }), - classify({ listedCount: 1, listedTitle: 'Untitled', describedTitle: 'Renamed' }), - ], - [ - RecoveryClassification.ConvergedMutated, - RecoveryClassification.ConvergedPreMutation, - RecoveryClassification.ConvergedMutated, - RecoveryClassification.ConvergedMutated, - ], - ); - }); - - test('losing, duplicating or failing to describe a session are recovery defects', () => { - assert.deepStrictEqual( - [ - classify({ listedCount: 0 }), - classify({ listedCount: 2, listedTitle: 'Renamed', describedTitle: 'Renamed' }), - classify({ listedCount: 1, listedTitle: 'Renamed', describeError: 'could not describe session yet' }), - ].map(classification => ({ classification, defect: isRecoveryDefect(classification) })), - [ - { classification: RecoveryClassification.Lost, defect: true }, - { classification: RecoveryClassification.Duplicated, defect: true }, - { classification: RecoveryClassification.Undescribable, defect: true }, - ], - ); - }); - - test('duplication is a defect even when the duplicate carries the expected title', () => { - // Guards the ordering inside the classifier: a duplicated session whose - // entries both look correct must not be mistaken for a clean recovery. - assert.strictEqual( - classify({ listedCount: 3, listedTitle: 'Renamed', describedTitle: 'Renamed' }), - RecoveryClassification.Duplicated, - ); - }); - - test('an empty restored title is a pre-mutation convergence, not an undescribable session', () => { - // A session that describes with no title at all has been recovered; only - // a `subscribe` that never succeeded leaves `describedTitle` undefined. - assert.deepStrictEqual( - [ - classify({ listedCount: 1, describedTitle: '' }), - classify({ listedCount: 1, describeError: 'transient' }), - ], - [RecoveryClassification.ConvergedPreMutation, RecoveryClassification.Undescribable], - ); - }); - - test('the run tallies admissible shapes so a durability gap is visible even when green', () => { - assert.deepStrictEqual( - tallyClassifications([ - scenarioResult([RecoveryClassification.ConvergedMutated, RecoveryClassification.ConvergedPreMutation]), - scenarioResult([RecoveryClassification.ConvergedMutated]), - scenarioResult([]), - ]), - { - [RecoveryClassification.ConvergedMutated]: 2, - [RecoveryClassification.ConvergedPreMutation]: 1, - }, - ); - }); - - test('both admissible shapes are always reported, so a run cannot hide a zero', () => { - // A run in which no rename ever survived must report `0`, not omit the - // key — an omitted key reads as "not measured" rather than "never held". - assert.deepStrictEqual( - tallyClassifications([scenarioResult([])]), - { - [RecoveryClassification.ConvergedMutated]: 0, - [RecoveryClassification.ConvergedPreMutation]: 0, - }, - ); - }); - - test('every uncovered boundary names a scoped integration proposal, and every proposal names an existing-coverage gap', () => { - const uncovered = RECOVERY_BOUNDARIES.filter(boundary => !boundary.covered).map(boundary => boundary.id); - assert.deepStrictEqual( - { - uncovered, - proposed: RECOVERY_INTEGRATION_PROPOSALS.map(proposal => proposal.boundaryId), - // A proposal that does not state what already exists invites - // duplicating a green test instead of closing the real gap. - allStateAGap: RECOVERY_INTEGRATION_PROPOSALS.every(proposal => - proposal.existingCoverage.some(entry => entry.startsWith('Gap:'))), - coveredHaveScenarios: RECOVERY_BOUNDARIES - .filter(boundary => boundary.covered) - .every(boundary => boundary.detail.includes('scenario')), - }, - { - uncovered: ['torn-write-corruption', 'pending-receipt-at-kill'], - proposed: ['torn-write-corruption', 'pending-receipt-at-kill'], - allStateAGap: true, - coveredHaveScenarios: true, - }, - ); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.ts deleted file mode 100644 index 9f4bb796cc89f1..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/recoveryMatrix.ts +++ /dev/null @@ -1,828 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Live process-recovery scenarios: what survives when the Agent Host is not - * asked to shut down, but simply stops existing. - * - * The same-build restart baseline establishes that a build can reopen its own - * profile after a **graceful** shutdown — stdin closed, flush awaited, process - * exited. That is the easy half. This file covers the half a user actually - * meets: the machine slept and the socket died, the process was OOM-killed, a - * container was reaped, or someone hit the power button mid-rename. - * - * Every scenario here therefore ends its first phase with `SIGKILL`. There is - * no shutdown handshake, no flush, no chance for the host to tidy up; whatever - * reached disk before the signal is the entire inheritance of the next process. - * - * ## Externality is preserved, and it constrains what can be claimed - * - * These scenarios obey the same rule as the rest of the E2E suite: the host is - * reached **only** over AHP on a WebSocket. Nothing here opens the host's - * database, reads its catalogue file, parses its logs for assertions, or - * imports host internals. That rule is what makes a passing result mean - * something — but it also bounds what can honestly be tested, and the bound is - * stated rather than papered over: - * - * - A black-box client can kill the process at an **AHP-observable** boundary - * (a request that has returned, a mutation a readback already reflects). It - * cannot kill it at an *internal* boundary — mid-write, between a receipt - * being queued and being fsynced, or with a deliberately truncated file — - * because it cannot see or create those states from outside. - * - Consequently the exact corruption and pending-receipt boundaries are - * **not** claimed by this file. {@link RECOVERY_BOUNDARIES} records them as - * explicitly out of black-box reach, and they are routed to a separately - * scoped integration test rather than faked with a plausible-looking E2E. - * - * ## Durability is measured, not assumed - * - * The host exposes no durability acknowledgment. `subscribe` and `listSessions` - * are served from memory, so a rename is *readable* long before it is - * *durable*, and the catalogue write is queued fire-and-forget behind them — - * and is not covered by the shutdown flush even when there is one, which after - * `SIGKILL` there is not. - * - * So a scenario that kills at the readback boundary cannot assert "the rename - * survived": that would encode a guarantee the host does not make, and would - * flake as a function of disk speed. What it asserts instead is the property - * that genuinely must hold — **convergence**: - * - * ```text - * admissible after an unclean kill inadmissible, and asserted against - * ──────────────────────────────── ────────────────────────────────── - * session present, new title session missing entirely - * session present, previous title session duplicated - * session present but undescribable - * ``` - * - * Losing the *rename* is a known durability gap. Losing the *session*, or - * growing a second copy of it, is a recovery defect. {@link classifyRecovery} - * draws exactly that line, and the observed side of it is reported in the JSON - * so the durability gap stays visible as data instead of being hidden by a - * tolerant assertion. - * - * ## The scenarios - * - * ```text - * A unclean-kill-restart create ▸ rename ▸ settle ▸ KILL ▸ restart ▸ list+subscribe - * B repeated-unclean-restart (KILL ▸ restart) × N, asserting no duplicates accumulate - * C kill-at-mutation-boundary rename ▸ readback returns ▸ KILL immediately ▸ converge - * D unclean-predecessor-upgrade historical build ▸ KILL ▸ current build on the same profile - * ``` - * - * Scenario D is the one that matters for a migration: it proves the current - * build's upgrade path is entered from state a previous build abandoned - * mid-flight, which is the realistic input to a migration and the one a clean - * hand-off never produces. - * - * All of it runs against the scripted mock provider — tokenless, networkless, - * fixture-free — so the subject is the *host's* recovery rather than a - * provider's replay state. - */ - -import { mkdirSync, mkdtempSync } from 'fs'; -import { tmpdir } from 'os'; -import { timeout } from '../../../../../../base/common/async.js'; -import { join } from '../../../../../../base/common/path.js'; -import { LiveCompatAhpClient } from './agentHostLiveCompatClient.js'; -import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; -import { - createAgentHostCapabilityAdapter, - LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, - type IAgentHostCapabilityAdapter, -} from './agentHostLiveCompatCapabilities.js'; -import { startLiveCompatServer, type ILiveCompatLaunchOptions, type ILiveCompatServerHandle } from './agentHostLiveCompatServer.js'; -import type { - AgentProviderCapabilities, - ILiveCompatInitializeResult, - ILiveCompatSessionList, - ILiveCompatSubscribeResult, -} from './agentHostLiveCompatProtocol.js'; -import type { ILiveCompatStepResult } from './sameBuildRestartBaseline.js'; - -/** Root channel URI. A constant of the protocol, stable across every build. */ -const ROOT_CHANNEL = 'ahp-root://'; -/** Provider the scenarios drive; see the file header for why it is the mock. */ -const PROVIDER = 'mock'; -const PER_CALL_TIMEOUT_MS = 30_000; - -/** Title dispatched before the kill, and looked for after it. */ -const TITLE_BEFORE_KILL = 'Recovery Matrix Renamed'; -/** Title used by scenario C, dispatched at the boundary the kill races. */ -const BOUNDARY_TITLE = 'Recovery Matrix Boundary'; - -/** - * A restored session is not describable the instant the host accepts - * connections: the provider is re-registered and the catalogue re-read - * concurrently with the socket opening. Retrying is part of the client - * contract, but the budget is bounded so a genuinely lost session still fails. - */ -const RESTORE_ATTEMPTS = 20; -const RESTORE_RETRY_DELAY_MS = 500; - -/** - * Settle window used **only** by scenario A, which is the scenario asking - * "does a rename that had time to persist survive an unclean kill?". Scenario C - * deliberately has no settle window — racing that write is its entire subject. - */ -const PERSIST_SETTLE_MS = 1_000; - -/** How many kill/restart cycles scenario B performs. */ -const CONVERGENCE_CYCLES = 3; - -/** - * Where a recovered session landed, relative to the mutation the kill raced. - * - * The first two are admissible outcomes of an unclean kill; the last two are - * recovery defects. Keeping them as one enumeration is what lets a scenario - * both *assert* (no defect) and *report* (which admissible outcome occurred) - * from a single observation. - */ -export const enum RecoveryClassification { - /** The mutation was durable: it survived the kill. */ - ConvergedMutated = 'converged-mutated', - /** The session survived, the mutation did not. A known durability gap. */ - ConvergedPreMutation = 'converged-pre-mutation', - /** The session is gone. A recovery defect. */ - Lost = 'lost', - /** The session came back more than once. A recovery defect. */ - Duplicated = 'duplicated', - /** Listed but not describable within the restore budget. A defect. */ - Undescribable = 'undescribable', -} - -/** What the client observed about one session after a restart. */ -export interface IRecoveryObservation { - /** How many list entries carried the session's resource. */ - readonly listedCount: number; - /** Title on the list entry, when listed. */ - readonly listedTitle?: string; - /** Title from a successful `subscribe`, when it succeeded. */ - readonly describedTitle?: string; - /** Why `subscribe` never succeeded, when it did not. */ - readonly describeError?: string; -} - -/** - * Decide whether a recovery was admissible, and which admissible shape it took. - * - * Pure so it can be tested against every observation shape without launching a - * process — including the shapes a live run is not guaranteed to produce, which - * are precisely the ones whose handling must not be assumed. - */ -export function classifyRecovery( - observation: IRecoveryObservation, - titles: { readonly beforeMutation?: string; readonly afterMutation: string }, -): RecoveryClassification { - if (observation.listedCount > 1) { - return RecoveryClassification.Duplicated; - } - if (observation.listedCount === 0) { - return RecoveryClassification.Lost; - } - if (observation.describedTitle === undefined) { - return RecoveryClassification.Undescribable; - } - // The list entry and the description are two different surfaces over the - // same durable state; the mutation counts as durable when either surface - // reports it, since a build may restore a title to one before the other. - if (observation.describedTitle === titles.afterMutation || observation.listedTitle === titles.afterMutation) { - return RecoveryClassification.ConvergedMutated; - } - return RecoveryClassification.ConvergedPreMutation; -} - -/** Whether a classification is a recovery defect (as opposed to a durability gap). */ -export function isRecoveryDefect(classification: RecoveryClassification): boolean { - return classification === RecoveryClassification.Lost - || classification === RecoveryClassification.Duplicated - || classification === RecoveryClassification.Undescribable; -} - -/** - * A boundary this matrix either covers or explicitly does not. - * - * Recorded as data, and emitted into the run's JSON, so that "what was not - * tested" is a first-class output rather than something a reader has to infer - * from the absence of a scenario. - */ -export interface IRecoveryBoundary { - readonly id: string; - readonly description: string; - readonly covered: boolean; - /** Scenario covering it, or why it is out of black-box reach. */ - readonly detail: string; -} - -export const RECOVERY_BOUNDARIES: readonly IRecoveryBoundary[] = Object.freeze([ - { - id: 'unclean-exit-after-graceful-quiescence', - description: 'Process killed with no shutdown handshake after its writes had time to settle.', - covered: true, - detail: 'scenario unclean-kill-restart', - }, - { - id: 'repeated-unclean-exit', - description: 'Repeated kill/restart cycles converge without accumulating duplicate sessions.', - covered: true, - detail: 'scenario repeated-unclean-restart', - }, - { - id: 'unclean-exit-at-mutation-readback', - description: 'Process killed immediately after a metadata mutation became AHP-observable.', - covered: true, - detail: 'scenario kill-at-mutation-boundary, killed at the readback response boundary with no intervening sleep', - }, - { - id: 'unclean-predecessor-handoff', - description: 'A newer build opens a profile a previous build abandoned without shutting down.', - covered: true, - detail: 'scenario unclean-predecessor-upgrade', - }, - { - id: 'torn-write-corruption', - description: 'Recovery from a partially-written or truncated persistence file.', - covered: false, - detail: 'not reachable over AHP: a black-box client cannot truncate host-owned files, and doing so would violate the suite externality rule. Routed to a scoped integration test — see RECOVERY_INTEGRATION_PROPOSALS.', - }, - { - id: 'pending-receipt-at-kill', - description: 'A write queued but not yet flushed when the process dies.', - covered: false, - detail: 'not reachable over AHP: the queue is internal and the host advertises no durability acknowledgment, so the boundary cannot be observed or targeted from outside. Routed to a scoped integration test — see RECOVERY_INTEGRATION_PROPOSALS.', - }, -]); - -/** - * Boundaries that need host internals, described precisely enough to be - * implemented as integration tests without re-deriving the analysis. - * - * Deliberately data in this file rather than prose in a document: it is - * emitted with the run, so the proposal travels with the evidence that - * motivated it. - */ -export interface IRecoveryIntegrationProposal { - readonly boundaryId: string; - /** Where such a test belongs, given it may import internals. */ - readonly suggestedLocation: string; - /** How to reach the boundary once internals are in scope. */ - readonly approach: string; - /** What the test would assert. */ - readonly assertion: string; - /** - * Coverage that already exists nearby, so the proposal is scoped to the - * genuine gap rather than duplicating a test that is already green. - */ - readonly existingCoverage: readonly string[]; -} - -export const RECOVERY_INTEGRATION_PROPOSALS: readonly IRecoveryIntegrationProposal[] = Object.freeze([ - { - boundaryId: 'torn-write-corruption', - suggestedLocation: 'src/vs/platform/agentHost/test/node/sessionDatabase.test.ts and agentHostDatabase.test.ts — they already own these stores and may import them directly, which an E2E must not.', - approach: 'Open the SQLite database against a temp directory, write a session, close it, then damage the file in place before reopening on the same path: truncate mid-page, zero the header, leave an orphaned -wal/-shm pair with no main database, and set an unknown (future) schema version. Reopen and drive the normal read path.', - assertion: 'Reopening resolves rather than rejecting, undamaged rows are still returned, and a database that cannot be salvaged is quarantined rather than deleted alongside adjacent good state. Each damage shape is its own case so a regression names the shape it broke. The unknown-schema-version case should assert a refusal to downgrade, not a silent reformat.', - existingCoverage: [ - 'sessionDatabase.test.ts:33 — transient initialization failure is retried.', - 'sessionDatabase.test.ts:112-170 — migrations apply, reopen, and roll back on failure.', - 'agentHostDatabase.test.ts:178-870 — schema creation and v4→v6→v8 upgrades, including legacy migration.', - 'agentHostCatalogListReader.test.ts:167 — unusable catalogue rows fall back rather than throwing.', - 'sessionArtifacts.test.ts:87 — malformed JSON input is handled.', - 'Gap: none of these damage the SQLite file itself; all corruption coverage today is at the row/JSON level.', - ], - }, - { - boundaryId: 'pending-receipt-at-kill', - suggestedLocation: 'src/vs/platform/agentHost/test/node/sessionDatabase.test.ts (write tracking) and agentHostCatalogSyncService.test.ts (pending receipts) — both already instantiate the machinery this boundary lives in.', - approach: 'Issue a mutation and, deliberately **without** awaiting `SessionDatabase.whenIdle()`, open a second database over the same path — modelling the process dying between a fire-and-forget write being tracked in `_pendingWrites` and the query completing. For the catalogue, hold a pending payload unflushed and reopen. The existing `_track` seam makes this deterministic without a sleep, which is exactly what a black-box client cannot achieve.', - assertion: 'The second reader observes either the pre-mutation or the post-mutation state and never a partial or duplicated one — the same convergence contract this E2E matrix asserts from outside, pinned here at the boundary the E2E cannot target. Pair it with a test asserting that skipping `whenIdle()` is the *only* way to lose the write, which turns the current comment at sessionDatabase.ts:358 into an executable claim.', - existingCoverage: [ - 'sessionDatabase.test.ts:583-609 — fire-and-forget writes and truncation, usage tracking.', - 'sessionDatabase.test.ts:669-719 — dispose behaviour, including dispose-during-open.', - 'sessionDatabase.ts:1065-1069 — `whenIdle()` drains `_pendingWrites`; :1083-1089 — `_track()` wraps public mutators.', - 'agentHostCatalogSyncService.test.ts:172,180,253,361 — local/central write failure, concurrent conflict, and queued mutations retaining caller payloads.', - 'Gap: every case above exercises the graceful path where `whenIdle()` is awaited; none models the process disappearing while `_pendingWrites` is non-empty.', - ], - }, -]); - -/** Machine-readable result of one recovery scenario. */ -export interface IRecoveryScenarioResult { - readonly scenario: string; - readonly build: string; - readonly buildDescription?: string; - /** Second build, for scenarios that hand a profile between builds. */ - readonly secondBuild?: string; - readonly outcome: 'passed' | 'failed'; - readonly durationMs: number; - readonly protocolVersion?: string; - readonly steps: readonly ILiveCompatStepResult[]; - /** How each restart classified; the durability gap is visible here. */ - readonly classifications: readonly RecoveryClassification[]; - /** Retained directory holding home, user-data (host logs) and workspace. */ - readonly diagnosticsPath: string; - readonly error?: string; -} - -export interface IRecoveryScenarioOptions { - readonly diagnosticsRoot?: string; - readonly env?: Readonly>; -} - -/** Records step outcomes and their durations in performance order. */ -class StepRecorder { - private readonly _steps: ILiveCompatStepResult[] = []; - - get steps(): readonly ILiveCompatStepResult[] { - return this._steps; - } - - async run(name: string, body: () => Promise): Promise { - const startedAt = Date.now(); - try { - const result = await body(); - this._steps.push({ name, outcome: 'passed', durationMs: Date.now() - startedAt }); - return result; - } catch (error) { - this._steps.push({ name, outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }); - throw error; - } - } - - note(name: string, detail: string): void { - this._steps.push({ name, outcome: 'passed', durationMs: 0, detail }); - } - - skip(name: string, reason: string): void { - this._steps.push({ name, outcome: 'skipped', durationMs: 0, detail: reason }); - } -} - -/** - * The scenario driver: one profile, a client that can be reconnected, and a - * process that can be killed rather than asked to leave. - * - * Exists so the four scenarios differ only in *when* they kill and *what* they - * assert afterwards, instead of each re-deriving launch/connect/kill. - */ -class RecoverySession { - private _server: ILiveCompatServerHandle | undefined; - private _client: LiveCompatAhpClient | undefined; - private _clientSeq = 0; - protocolVersion: string | undefined; - - constructor( - private readonly _launch: ILiveCompatLaunchOptions, - private readonly _clientIdPrefix: string, - ) { } - - get client(): LiveCompatAhpClient { - if (!this._client) { - throw new Error('[agent-host-recovery] no live connection; the host is not running'); - } - return this._client; - } - - /** Launch a build on this profile and complete the AHP handshake. */ - async start(phase: string, overrides?: Partial): Promise { - this._server = await startLiveCompatServer({ ...this._launch, ...overrides }); - const client = new LiveCompatAhpClient(this._server.port); - await client.connect(); - this._client = client; - const initialize = await client.call('initialize', { - channel: ROOT_CHANNEL, - protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], - clientId: `${this._clientIdPrefix}-${phase}`, - }, PER_CALL_TIMEOUT_MS); - this.protocolVersion = initialize.protocolVersion; - return createAgentHostCapabilityAdapter({ - protocolVersion: initialize.protocolVersion, - providerCapabilities: await this._readProviderCapabilities(client), - }); - } - - /** - * Kill the host outright and wait for the process to be reaped. - * - * `SIGKILL` rather than `SIGTERM` or closing stdin, and that choice is the - * point of this file: the host gets no handler, no flush and no chance to - * write a clean marker, which is exactly the state a crash leaves behind. - * - * Awaiting the exit is load-bearing for a different reason — the next phase - * reopens the same user-data directory, and a not-yet-reaped predecessor - * would still hold it, turning a recovery result into a race. - */ - async kill(): Promise { - const child = this._server?.process; - this._client?.close(); - this._client = undefined; - this._server = undefined; - if (!child || child.exitCode !== null || child.signalCode !== null) { - return; - } - const exited = new Promise(resolve => child.once('exit', () => resolve())); - child.kill('SIGKILL'); - await exited; - } - - /** Best-effort teardown for the failure path. */ - async dispose(): Promise { - await this.kill().catch(() => undefined); - } - - /** Create a session and confirm it is subscribable before returning. */ - async createSession(uri: string): Promise { - await this.client.call('createSession', { channel: uri, provider: PROVIDER }, PER_CALL_TIMEOUT_MS); - await this.client.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); - } - - /** - * Dispatch a rename and return once a readback reflects it. - * - * The returned promise settling **is** the boundary scenario C kills at: a - * response the host has already produced, not an elapsed duration. That is - * what makes the race deterministic in the only sense available from - * outside — the kill provably lands after the reducer ran, and provably - * without waiting for anything else. - */ - async renameAndAwaitReadback(uri: string, title: string): Promise { - this.client.notify('dispatchAction', { - channel: uri, - clientSeq: ++this._clientSeq, - action: { type: 'session/titleChanged', title }, - }); - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - const subscribed = await this.client.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); - if (subscribed.snapshot?.state?.title === title) { - return; - } - await timeout(RESTORE_RETRY_DELAY_MS); - } - throw new Error(`[agent-host-recovery] '${title}' was never observable on ${uri} before the kill`); - } - - /** Observe a session across both surfaces, tolerating the describe window. */ - async observe(uri: string): Promise { - const listed = await this.client.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - const matches = (listed.items ?? []).filter(item => item.resource === uri); - if (matches.length === 0) { - return { listedCount: 0 }; - } - let describedTitle: string | undefined; - let describeError: string | undefined; - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - try { - const subscribed = await this.client.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); - describedTitle = subscribed.snapshot?.state?.title ?? ''; - describeError = undefined; - break; - } catch (error) { - describeError = messageOf(error); - await timeout(RESTORE_RETRY_DELAY_MS); - } - } - return { listedCount: matches.length, listedTitle: matches[0].title, describedTitle, describeError }; - } - - /** Read provider capabilities off the root snapshot, as any client would. */ - private async _readProviderCapabilities(client: LiveCompatAhpClient): Promise> { - const root = await client.call('subscribe', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - const capabilities = new Map(); - for (const agent of root.snapshot?.state?.agents ?? []) { - capabilities.set(agent.provider, agent.capabilities ?? {}); - } - return capabilities; - } -} - -/** - * Scenario A — a crash after the host had reached quiescence. - * - * The mildest unclean exit there is, and therefore the one whose failure is - * least ambiguous: the rename was given time to reach disk, so anything missing - * afterwards was lost by recovery rather than by the race. - */ -export async function runUncleanKillRestart( - build: IPreparedAgentHostBuild, - options: IRecoveryScenarioOptions = {}, -): Promise { - return runScenario('unclean-kill-restart', build, options, async (session, recorder, context) => { - const adapter = await recorder.run('launch-and-initialize', () => session.start('seed')); - const uri = context.sessionUri; - - await recorder.run('create-session', () => session.createSession(uri)); - await renameStep(recorder, session, adapter, uri, TITLE_BEFORE_KILL); - - await recorder.run('settle-writes', async () => { - // Scenario A's question is about recovery, not about racing the - // catalogue write, so the write is deliberately given time. The - // race itself is scenario C's subject. - await timeout(PERSIST_SETTLE_MS); - }); - - await recorder.run('sigkill', () => session.kill()); - await recorder.run('relaunch', () => session.start('verify', { env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri } })); - - const observation = await recorder.run('observe-recovered', () => session.observe(uri)); - const classification = classifyRecovery(observation, { afterMutation: TITLE_BEFORE_KILL }); - recorder.note('classify', `${classification} (${describeObservation(observation)})`); - assertNoDefect(classification, observation, 'after an unclean kill that followed a settled rename'); - if (adapter.supportsSessionRename && classification === RecoveryClassification.ConvergedPreMutation) { - // Reported, not failed: durability of the catalogue write is a - // host-side gap this suite measures rather than legislates. - recorder.note('durability-gap', `the rename was readable before the kill but did not survive it; observed title '${observation.describedTitle ?? ''}'`); - } - return [classification]; - }); -} - -/** - * Scenario B — repeated crashes must converge, not accumulate. - * - * One kill/restart proves recovery works once. The failure mode this scenario - * exists for is the one that only appears when recovery runs against state a - * previous recovery produced: a restored session written back as a *new* entry, - * so each crash leaves the profile with one more copy of the same session. - * Nothing in a single-cycle test can see that. - */ -export async function runRepeatedUncleanRestart( - build: IPreparedAgentHostBuild, - options: IRecoveryScenarioOptions = {}, -): Promise { - return runScenario('repeated-unclean-restart', build, options, async (session, recorder, context) => { - const adapter = await recorder.run('launch-and-initialize', () => session.start('seed')); - const uri = context.sessionUri; - await recorder.run('create-session', () => session.createSession(uri)); - // Renamed before the first kill so each cycle's classification is a real - // verdict. Without a mutation to compare against, every cycle would - // trivially report "pre-mutation" and the tally would look like a - // durability failure that never happened. - await renameStep(recorder, session, adapter, uri, TITLE_BEFORE_KILL); - await recorder.run('settle-writes', () => timeout(PERSIST_SETTLE_MS)); - - const classifications: RecoveryClassification[] = []; - for (let cycle = 1; cycle <= CONVERGENCE_CYCLES; cycle++) { - await recorder.run(`sigkill-${cycle}`, () => session.kill()); - await recorder.run(`relaunch-${cycle}`, () => session.start(`cycle-${cycle}`, { - env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri }, - })); - const observation = await recorder.run(`observe-${cycle}`, () => session.observe(uri)); - const classification = classifyRecovery(observation, { afterMutation: TITLE_BEFORE_KILL }); - recorder.note(`classify-${cycle}`, `${classification} (${describeObservation(observation)})`); - // The duplicate check is the load-bearing assertion: it is why the - // scenario loops instead of killing once. - assertNoDefect(classification, observation, `on unclean restart cycle ${cycle} of ${CONVERGENCE_CYCLES}`); - classifications.push(classification); - } - return classifications; - }); -} - -/** - * Scenario C — kill at the moment a mutation becomes observable. - * - * The kill is issued as the next statement after the readback resolves: no - * sleep, no polling interval, nothing that makes the timing a function of the - * machine. The host has demonstrably reduced the action (the readback proves - * it) and has demonstrably not been given time to do anything else. - * - * Both outcomes are admissible and both are recorded. What is asserted is only - * that the *session* survives the race intact — the property that must hold - * regardless of where the write landed. - */ -export async function runKillAtMutationBoundary( - build: IPreparedAgentHostBuild, - options: IRecoveryScenarioOptions = {}, -): Promise { - return runScenario('kill-at-mutation-boundary', build, options, async (session, recorder, context) => { - const adapter = await recorder.run('launch-and-initialize', () => session.start('seed')); - const uri = context.sessionUri; - await recorder.run('create-session', () => session.createSession(uri)); - await recorder.run('settle-session-creation', () => timeout(PERSIST_SETTLE_MS)); - - if (!adapter.supportsSessionRename) { - recorder.skip('kill-at-boundary', `negotiated protocol ${adapter.protocolVersion} predates client-dispatchable session/titleChanged`); - await recorder.run('sigkill', () => session.kill()); - await recorder.run('relaunch', () => session.start('verify', { env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri } })); - const plain = await recorder.run('observe-recovered', () => session.observe(uri)); - const plainClassification = classifyRecovery(plain, { afterMutation: BOUNDARY_TITLE }); - assertNoDefect(plainClassification, plain, 'after an unclean kill on a build without dispatchable rename'); - return [plainClassification]; - } - - await recorder.run('mutate-and-kill-at-readback', async () => { - await session.renameAndAwaitReadback(uri, BOUNDARY_TITLE); - // Immediately, on purpose: this adjacency is the experiment. - await session.kill(); - }); - - await recorder.run('relaunch', () => session.start('verify', { env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri } })); - const observation = await recorder.run('observe-recovered', () => session.observe(uri)); - const classification = classifyRecovery(observation, { afterMutation: BOUNDARY_TITLE }); - recorder.note('classify', `${classification} (${describeObservation(observation)})`); - recorder.note('boundary-outcome', classification === RecoveryClassification.ConvergedMutated - ? 'the mutation was already durable when the process died' - : 'the mutation was readable but not yet durable when the process died — the known catalogue-write gap, observed'); - assertNoDefect(classification, observation, 'after a kill at the mutation readback boundary'); - return [classification]; - }); -} - -/** - * Scenario D — a newer build inherits a profile nobody closed. - * - * A migration's real input is not a tidy profile handed over by a graceful - * shutdown; it is whatever a previous build left when it stopped. This scenario - * produces exactly that input — historical build, `SIGKILL`, current build on - * the same directories — and asserts the upgrade path survives it. - * - * It is the only scenario spanning two builds, so it is also the only one whose - * failure could mean either "recovery is broken" or "migration is broken"; the - * per-phase steps and the retained diagnostics directory are what separate the - * two after the fact. - */ -export async function runUncleanPredecessorUpgrade( - historical: IPreparedAgentHostBuild, - current: IPreparedAgentHostBuild, - options: IRecoveryScenarioOptions = {}, -): Promise { - const startedAt = Date.now(); - const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-recovery-upgrade-${historical.id}-to-${current.id}-`)); - const dirs = createPersistentDirectories(diagnosticsPath); - const recorder = new StepRecorder(); - const uri = `${PROVIDER}:/recovery-upgrade-${Date.now()}`; - const classifications: RecoveryClassification[] = []; - - const base = { homeDir: dirs.homeDir, userDataDir: dirs.userDataDir, env: options.env }; - const predecessor = new RecoverySession({ ...base, serverEntry: historical.serverEntry }, `recovery-${historical.id}`); - const successor = new RecoverySession({ ...base, serverEntry: current.serverEntry }, `recovery-${current.id}`); - let protocolVersion: string | undefined; - - try { - const adapter = await recorder.run('launch-historical', () => predecessor.start('seed')); - recorder.note('historical-protocol', `${historical.id} negotiated ${adapter.protocolVersion}`); - await recorder.run('create-session-on-historical', () => predecessor.createSession(uri)); - await renameStep(recorder, predecessor, adapter, uri, TITLE_BEFORE_KILL); - await recorder.run('settle-writes', () => timeout(PERSIST_SETTLE_MS)); - // No shutdown handshake: the successor must enter migration from state - // the predecessor abandoned, which is the whole point of the scenario. - await recorder.run('sigkill-historical', () => predecessor.kill()); - - await recorder.run('launch-current-on-abandoned-profile', async () => { - await successor.start('verify', { env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: uri } }); - }); - protocolVersion = successor.protocolVersion; - - const observation = await recorder.run('observe-migrated', () => successor.observe(uri)); - const classification = classifyRecovery(observation, { afterMutation: TITLE_BEFORE_KILL }); - recorder.note('classify', `${classification} (${describeObservation(observation)})`); - assertNoDefect(classification, observation, `after ${current.id} opened a profile ${historical.id} abandoned without shutting down`); - classifications.push(classification); - - return { - scenario: 'unclean-predecessor-upgrade', - build: historical.id, - buildDescription: historical.description, - secondBuild: current.id, - outcome: 'passed', - durationMs: Date.now() - startedAt, - protocolVersion, - steps: recorder.steps, - classifications, - diagnosticsPath, - }; - } catch (error) { - return { - scenario: 'unclean-predecessor-upgrade', - build: historical.id, - buildDescription: historical.description, - secondBuild: current.id, - outcome: 'failed', - durationMs: Date.now() - startedAt, - protocolVersion, - steps: recorder.steps, - classifications, - diagnosticsPath, - error: messageOf(error), - }; - } finally { - await predecessor.dispose(); - await successor.dispose(); - } -} - -interface IScenarioContext { - readonly sessionUri: string; -} - -/** - * Shared scaffolding for the single-build scenarios. - * - * Never throws for a scenario failure: a failed build is data the caller needs - * alongside the builds that passed, so the failure is reported in the result. - */ -async function runScenario( - scenario: string, - build: IPreparedAgentHostBuild, - options: IRecoveryScenarioOptions, - body: (session: RecoverySession, recorder: StepRecorder, context: IScenarioContext) => Promise, -): Promise { - const startedAt = Date.now(); - const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-recovery-${scenario}-${build.id}-`)); - const dirs = createPersistentDirectories(diagnosticsPath); - const recorder = new StepRecorder(); - const session = new RecoverySession({ - serverEntry: build.serverEntry, - homeDir: dirs.homeDir, - userDataDir: dirs.userDataDir, - env: options.env, - }, `recovery-${build.id}`); - const context: IScenarioContext = { sessionUri: `${PROVIDER}:/recovery-${scenario}-${build.id}-${Date.now()}` }; - - try { - const classifications = await body(session, recorder, context); - return { - scenario, - build: build.id, - buildDescription: build.description, - outcome: 'passed', - durationMs: Date.now() - startedAt, - protocolVersion: session.protocolVersion, - steps: recorder.steps, - classifications, - diagnosticsPath, - }; - } catch (error) { - return { - scenario, - build: build.id, - buildDescription: build.description, - outcome: 'failed', - durationMs: Date.now() - startedAt, - protocolVersion: session.protocolVersion, - steps: recorder.steps, - classifications: [], - diagnosticsPath, - error: messageOf(error), - }; - } finally { - await session.dispose(); - } -} - -/** - * Rename, or record why the build cannot be asked to. - * - * Skipped rather than omitted: a build too old to dispatch a rename still runs - * the recovery scenario, and stating the omission keeps the result's coverage - * honest instead of silently narrower than it looks. - */ -async function renameStep( - recorder: StepRecorder, - session: RecoverySession, - adapter: IAgentHostCapabilityAdapter, - uri: string, - title: string, -): Promise { - if (!adapter.supportsSessionRename) { - recorder.skip('rename-session', `negotiated protocol ${adapter.protocolVersion} predates client-dispatchable session/titleChanged`); - return; - } - await recorder.run('rename-session', () => session.renameAndAwaitReadback(uri, title)); -} - -function assertNoDefect(classification: RecoveryClassification, observation: IRecoveryObservation, context: string): void { - if (isRecoveryDefect(classification)) { - throw new Error(`[agent-host-recovery] ${classification} ${context}: ${describeObservation(observation)}`); - } -} - -function describeObservation(observation: IRecoveryObservation): string { - const parts = [`listed=${observation.listedCount}`]; - if (observation.listedTitle !== undefined) { - parts.push(`listedTitle='${observation.listedTitle}'`); - } - if (observation.describedTitle !== undefined) { - parts.push(`describedTitle='${observation.describedTitle}'`); - } - if (observation.describeError !== undefined) { - parts.push(`describeError=${observation.describeError}`); - } - return parts.join(', '); -} - -function createPersistentDirectories(root: string): { homeDir: string; userDataDir: string } { - const homeDir = join(root, 'home'); - const userDataDir = join(root, 'user-data'); - mkdirSync(homeDir, { recursive: true }); - mkdirSync(join(homeDir, '.codex'), { recursive: true }); - mkdirSync(userDataDir, { recursive: true }); - mkdirSync(join(root, 'workspace'), { recursive: true }); - return { homeDir, userDataDir }; -} - -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/runBackwardCompatibilityMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/runBackwardCompatibilityMatrix.ts deleted file mode 100644 index 2dee796a53c0c8..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/runBackwardCompatibilityMatrix.ts +++ /dev/null @@ -1,87 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Entry point that resolves checkpoints and runs the backward-compatibility - * round trips. - * - * Split from {@link backwardCompatibilityMatrix} so the scenario itself takes - * already-prepared builds and knows nothing about the repository, git, or the - * build cache. This module is the only place the two meet, and it inherits the - * matrix runner's governing rule: - * - * - **A pairing is never silently skipped.** A checkpoint that cannot be - * resolved becomes a *failed* result carrying the resolver's own explanation - * (which names the exact `--prepare` command to run), so a run that covered - * two of three pairings can never be mistaken for a run that covered three. - */ - -import { runBackwardCompatibilityRoundTrip, unresolvedBackwardCompatResult, type IBackwardCompatMatrixSummary, type IBackwardCompatScenarioResult } from './backwardCompatibilityMatrix.js'; -import { AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; -import { resolveBuild, type ILiveCompatMatrixOptions } from './agentHostLiveCompatMatrix.js'; -import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; - -/** - * Older checkpoints the current build is handed down to, oldest first. - * - * Ordering is deliberate: the oldest build is the most likely to fail, and - * running it first means the longest-standing incompatibility is reported - * before time is spent on the closer ones. - */ -export const BACKWARD_COMPAT_OLDER_BUILDS: readonly string[] = Object.freeze([ - AgentHostBuildId.Legacy, - AgentHostBuildId.Intermediate, - AgentHostBuildId.Predecessor, -]); - -/** - * Run `current → older → current → restart` for each requested older build. - * - * Builds run sequentially; see {@link runBackwardCompatibilityMatrix} for why. - */ -export async function runBackwardCompatibilityMatrixForBuilds( - olderBuildIds: readonly string[], - options: ILiveCompatMatrixOptions, -): Promise { - const startedAt = Date.now(); - const results: IBackwardCompatScenarioResult[] = []; - - let current: IPreparedAgentHostBuild | undefined; - let currentProblem: string | undefined; - try { - current = resolveBuild(AgentHostBuildId.Current, options); - } catch (error) { - currentProblem = messageOf(error); - } - - for (const olderBuildId of olderBuildIds) { - if (!current) { - // Every pairing needs the current build, so its absence fails them - // all rather than aborting the run with a single opaque throw. - results.push(unresolvedBackwardCompatResult(AgentHostBuildId.Current, olderBuildId, currentProblem!)); - continue; - } - let older: IPreparedAgentHostBuild; - try { - older = resolveBuild(olderBuildId, options); - } catch (error) { - results.push(unresolvedBackwardCompatResult(current.id, olderBuildId, messageOf(error))); - continue; - } - results.push(await runBackwardCompatibilityRoundTrip(current, older, { diagnosticsRoot: options.diagnosticsRoot })); - } - - return { - suite: 'agent-host-live-compat/backward-compatibility-round-trip', - startedAt: new Date(startedAt).toISOString(), - durationMs: Date.now() - startedAt, - outcome: results.every(entry => entry.outcome === 'passed') ? 'passed' : 'failed', - results, - }; -} - -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/runForwardMigrationMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/runForwardMigrationMatrix.ts deleted file mode 100644 index 128ca7baecb244..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/runForwardMigrationMatrix.ts +++ /dev/null @@ -1,128 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Entry point for running the forward-migration matrix. - * - * The split from {@link forwardMigrationMatrix} is deliberate. That module - * knows how to drive two *already resolved* builds and nothing else, which is - * what makes it testable without a prepared cache. This module owns the messy - * outside world: turning checkpoint ids into launchable builds, deciding what - * pairs constitute "forward", and shaping a summary for a caller. - * - * Two rules carry over from the baseline matrix and are restated because they - * are properties of the *result*, not of the code: - * - * - **A pair is never silently skipped.** A checkpoint that cannot be resolved - * is reported as a failed entry carrying the resolver's own explanation - * (which names the exact `--prepare` command to run), so a run that covered - * two of three upgrades can never be mistaken for a run that covered three. - * - **Pairs run sequentially**, for the same attributability reason. - */ - -import { AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; -import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; -import { resolveBuild, type ILiveCompatMatrixOptions } from './agentHostLiveCompatMatrix.js'; -import { runForwardMigrationScenario, type IForwardMigrationSummary } from './forwardMigrationMatrix.js'; -import type { ILiveCompatScenarioResult } from './sameBuildRestartBaseline.js'; - -/** - * The source checkpoints, oldest first. Every one of them upgrades to the - * working tree, which is the only build a forward claim can be *about*. - */ -export const FORWARD_MIGRATION_SOURCES: readonly string[] = Object.freeze([ - AgentHostBuildId.Legacy, - AgentHostBuildId.Predecessor, - AgentHostBuildId.Intermediate, -]); - -export interface IRunForwardMigrationOptions extends ILiveCompatMatrixOptions { - /** Source checkpoints to upgrade from. Defaults to all three. */ - readonly sources?: readonly string[]; - /** - * Also run each pair with several sessions in the profile. - * - * Kept opt-out rather than opt-in: a single-session upgrade cannot detect a - * migration that preserves one row but conflates identities across a set, - * and that is a realistic failure mode. - */ - readonly includeMultiSession?: boolean; - /** How many sessions the multi-session variant seeds. */ - readonly multiSessionCount?: number; -} - -/** - * Run every forward-migration pair and summarize the outcome. - */ -export async function runForwardMigrations(options: IRunForwardMigrationOptions): Promise { - const startedAt = Date.now(); - const sources = options.sources ?? FORWARD_MIGRATION_SOURCES; - const includeMultiSession = options.includeMultiSession ?? true; - const multiSessionCount = options.multiSessionCount ?? 3; - const results: ILiveCompatScenarioResult[] = []; - - // Resolved once: a missing working tree is a property of the run, not of - // each pair, and re-resolving it per pair would repeat the same message - // three times while hiding that they share a single cause. - let target: IPreparedAgentHostBuild | undefined; - let targetError: string | undefined; - try { - target = resolveBuild(AgentHostBuildId.Current, options); - } catch (error) { - targetError = messageOf(error); - } - - for (const sourceId of sources) { - let source: IPreparedAgentHostBuild | undefined; - let sourceError: string | undefined; - try { - source = resolveBuild(sourceId, options); - } catch (error) { - sourceError = messageOf(error); - } - - const unresolved = sourceError ?? targetError; - if (unresolved || !source || !target) { - results.push(unresolvedResult(sourceId, unresolved ?? 'build could not be resolved', startedAt)); - continue; - } - - results.push(await runForwardMigrationScenario(source, target, { - diagnosticsRoot: options.diagnosticsRoot, - scenarioSuffix: 'single-session', - })); - if (includeMultiSession) { - results.push(await runForwardMigrationScenario(source, target, { - diagnosticsRoot: options.diagnosticsRoot, - sessionCount: multiSessionCount, - scenarioSuffix: 'multi-session', - })); - } - } - - return { - suite: 'agent-host-live-compat/forward-migration', - startedAt: new Date(startedAt).toISOString(), - durationMs: Date.now() - startedAt, - outcome: results.every(result => result.outcome === 'passed') ? 'passed' : 'failed', - results, - }; -} - -function unresolvedResult(sourceId: string, detail: string, startedAt: number): ILiveCompatScenarioResult { - return { - scenario: 'forward-migration', - build: `${sourceId}->${AgentHostBuildId.Current}`, - outcome: 'failed', - durationMs: Date.now() - startedAt, - steps: [{ name: 'resolve-build', outcome: 'failed', durationMs: 0, detail }], - diagnosticsPath: '', - error: detail, - }; -} - -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/runRecoveryMatrix.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/runRecoveryMatrix.ts deleted file mode 100644 index fe439f9d205636..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/runRecoveryMatrix.ts +++ /dev/null @@ -1,177 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Executes the process-recovery matrix and summarizes the outcome. - * - * The same two rules that govern the restart-baseline matrix apply here, for - * the same reasons: - * - * - **A build is never silently skipped.** A checkpoint that cannot be resolved - * is reported as a failed entry carrying the resolver's own explanation, so a - * run that covered three builds can never be mistaken for one that covered - * four. - * - **Scenarios run sequentially.** Each forks a real Agent Host and kills it - * with `SIGKILL`; overlapping two of those on one machine would make a - * failure attributable to contention rather than to recovery. - * - * A third rule is specific to this matrix: the summary carries - * {@link RECOVERY_BOUNDARIES} and {@link RECOVERY_INTEGRATION_PROPOSALS} - * verbatim. A recovery run's most misreadable property is its *scope*, so what - * was deliberately not covered ships inside the same artifact as what passed, - * rather than living in a document that can drift away from the evidence. - */ - -import { agentHostLiveCompatBuild, AgentHostBuildId } from '../harness/agentHostLiveCompatBuilds.js'; -import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; -import { resolveBuild, type ILiveCompatMatrixOptions } from './agentHostLiveCompatMatrix.js'; -import { - RECOVERY_BOUNDARIES, - RECOVERY_INTEGRATION_PROPOSALS, - RecoveryClassification, - runKillAtMutationBoundary, - runRepeatedUncleanRestart, - runUncleanKillRestart, - runUncleanPredecessorUpgrade, - type IRecoveryBoundary, - type IRecoveryIntegrationProposal, - type IRecoveryScenarioResult, -} from './recoveryMatrix.js'; - -/** Aggregate outcome of one recovery run. */ -export interface IRecoveryMatrixSummary { - readonly suite: string; - readonly startedAt: string; - readonly durationMs: number; - readonly outcome: 'passed' | 'failed'; - readonly results: readonly IRecoveryScenarioResult[]; - /** - * Tally of admissible recovery shapes across every restart performed. - * - * This is where the catalogue-write durability gap becomes visible as a - * number instead of an anecdote: a run that is green but whose renames - * never survive reports it here rather than looking indistinguishable from - * a run where durability held. - */ - readonly classificationCounts: Readonly>; - readonly boundaries: readonly IRecoveryBoundary[]; - readonly integrationProposals: readonly IRecoveryIntegrationProposal[]; -} - -export interface IRecoveryMatrixOptions extends ILiveCompatMatrixOptions { - /** - * Build the current-vs-historical upgrade scenario hands a profile from. - * Defaults to the predecessor checkpoint, the closest realistic upgrade. - */ - readonly upgradeFromBuildId?: AgentHostBuildId | string; -} - -/** - * Run every recovery scenario for each requested checkpoint, in order. - * - * The cross-build upgrade scenario is run once at the end rather than per - * build: it is a property of a *pair*, and running it for each requested build - * against itself would assert nothing a single-build scenario has not already. - */ -export async function runRecoveryMatrix( - buildIds: readonly (AgentHostBuildId | string)[], - options: IRecoveryMatrixOptions, -): Promise { - const startedAt = Date.now(); - const results: IRecoveryScenarioResult[] = []; - - for (const buildId of buildIds) { - const resolution = tryResolve(buildId, options); - if (!resolution.build) { - // One entry per scenario the build was going to run, so a resolution - // failure cannot shrink the apparent size of the matrix. - results.push( - failedResolution('unclean-kill-restart', buildId, resolution.error), - failedResolution('repeated-unclean-restart', buildId, resolution.error), - failedResolution('kill-at-mutation-boundary', buildId, resolution.error), - ); - continue; - } - const scenarioOptions = { diagnosticsRoot: options.diagnosticsRoot }; - results.push(await runUncleanKillRestart(resolution.build, scenarioOptions)); - results.push(await runRepeatedUncleanRestart(resolution.build, scenarioOptions)); - results.push(await runKillAtMutationBoundary(resolution.build, scenarioOptions)); - } - - results.push(await runUpgradeScenario(options)); - - return { - suite: 'agent-host-live-compat/recovery-matrix', - startedAt: new Date(startedAt).toISOString(), - durationMs: Date.now() - startedAt, - outcome: results.every(result => result.outcome === 'passed') ? 'passed' : 'failed', - results, - classificationCounts: tallyClassifications(results), - boundaries: RECOVERY_BOUNDARIES, - integrationProposals: RECOVERY_INTEGRATION_PROPOSALS, - }; -} - -/** - * Hand a profile from a historical build, killed uncleanly, to the current one. - * - * Both ends must resolve for the scenario to mean anything, so a failure to - * resolve either is reported as the scenario failing rather than as an absence. - */ -async function runUpgradeScenario(options: IRecoveryMatrixOptions): Promise { - const fromId = options.upgradeFromBuildId ?? AgentHostBuildId.Predecessor; - const from = tryResolve(fromId, options); - const to = tryResolve(AgentHostBuildId.Current, options); - if (!from.build || !to.build) { - const error = from.build ? to.error : from.error; - return failedResolution('unclean-predecessor-upgrade', fromId, error); - } - return runUncleanPredecessorUpgrade(from.build, to.build, { diagnosticsRoot: options.diagnosticsRoot }); -} - -/** Count each admissible recovery shape observed across the whole run. */ -export function tallyClassifications(results: readonly IRecoveryScenarioResult[]): Readonly> { - const counts: Record = { - [RecoveryClassification.ConvergedMutated]: 0, - [RecoveryClassification.ConvergedPreMutation]: 0, - }; - for (const result of results) { - for (const classification of result.classifications) { - counts[classification] = (counts[classification] ?? 0) + 1; - } - } - return counts; -} - -function tryResolve( - buildId: AgentHostBuildId | string, - options: ILiveCompatMatrixOptions, -): { build?: IPreparedAgentHostBuild; error: string } { - try { - // Validates the id against the known checkpoints before planning, so an - // unknown id reports as such rather than as a missing build directory. - agentHostLiveCompatBuild(buildId); - return { build: resolveBuild(buildId, options), error: '' }; - } catch (error) { - return { error: messageOf(error) }; - } -} - -function failedResolution(scenario: string, buildId: AgentHostBuildId | string, error: string): IRecoveryScenarioResult { - return { - scenario, - build: String(buildId), - outcome: 'failed', - durationMs: 0, - steps: [{ name: 'resolve-build', outcome: 'failed', durationMs: 0, detail: error }], - classifications: [], - diagnosticsPath: '', - error, - }; -} - -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/vs/platform/agentHost/test/node/e2e/liveCompat/sameBuildRestartBaseline.ts b/src/vs/platform/agentHost/test/node/e2e/liveCompat/sameBuildRestartBaseline.ts deleted file mode 100644 index c20242921a4fb6..00000000000000 --- a/src/vs/platform/agentHost/test/node/e2e/liveCompat/sameBuildRestartBaseline.ts +++ /dev/null @@ -1,403 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * The same-build restart baseline. - * - * Before any cross-version claim can mean anything, each checkpoint has to be - * shown to seed and reopen **its own** persistent profile. Otherwise a failure - * in a later upgrade or downgrade matrix is ambiguous: it could be a migration - * defect, or it could be that the build never round-tripped its own state. - * This scenario removes that ambiguity, one build at a time. - * - * Shape of a run, all of it over AHP against a real forked server process: - * - * ```text - * phase 1 (seed) restart, same build phase 2 (verify) - * initialize ─▶ list ─▶ create ─▶ rename list ─▶ subscribe - * (empty) (session + title survive) - * ``` - * - * Two properties are load-bearing: - * - * - **Same directories.** Both phases receive the identical home, user-data and - * workspace directories. The restart is the whole point; a fresh profile - * would make every assertion vacuous. - * - **External.** Nothing here imports host internals, reads the host database, - * or inspects logs for assertions. Contract evolution between builds is - * resolved through {@link IAgentHostCapabilityAdapter}, from what the build - * advertises — never from the checkpoint id. - * - * The scenario runs against the scripted mock provider. That is a deliberate - * choice, not a convenience: a bundled provider (Copilot/Claude/Codex) cannot - * re-describe a session after a restart until it has been materialized by a - * real model-backed turn, which would make an otherwise host-only baseline - * depend on replay fixtures recorded per build. The mock provider makes the - * baseline about the *host's* persistence, which is what is under test — and - * keeps the run tokenless, networkless and fixture-free. - */ - -import { mkdirSync, mkdtempSync } from 'fs'; -import { tmpdir } from 'os'; -import { timeout } from '../../../../../../base/common/async.js'; -import { join } from '../../../../../../base/common/path.js'; -import { LiveCompatAhpClient } from './agentHostLiveCompatClient.js'; -import type { IPreparedAgentHostBuild } from '../harness/crossVersionAgentHostTarget.js'; -import { - createAgentHostCapabilityAdapter, - LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS, - type IAgentHostCapabilityAdapter, -} from './agentHostLiveCompatCapabilities.js'; -import { startLiveCompatServer, stopLiveCompatServer, type ILiveCompatLaunchOptions, type ILiveCompatServerHandle } from './agentHostLiveCompatServer.js'; -import type { - AgentProviderCapabilities, - ILiveCompatInitializeResult, - ILiveCompatSessionList, - ILiveCompatSubscribeResult, -} from './agentHostLiveCompatProtocol.js'; - -/** Root channel URI. A constant of the protocol, stable across every build. */ -const ROOT_CHANNEL = 'ahp-root://'; -/** Provider the baseline drives; see the file header for why it is the mock. */ -const PROVIDER = 'mock'; -/** Title dispatched in phase 1 and expected back in phase 2. */ -const BASELINE_TITLE = 'Live Compat Baseline'; -const PER_CALL_TIMEOUT_MS = 30_000; - -/** - * A restored session is not necessarily describable the instant the host is - * accepting connections: the provider is re-registered and the catalogue - * re-read concurrently with the socket opening, and until that settles - * `subscribe` answers with a transient "could not describe … yet". Retrying is - * therefore part of the contract a client must implement, not a workaround — - * but the budget is bounded so a genuinely lost session still fails. - */ -const RESTORE_ATTEMPTS = 20; -const RESTORE_RETRY_DELAY_MS = 500; - -/** - * Time allowed for a rename's catalogue write to reach disk before the host is - * restarted. See the note at its use site: the host exposes no acknowledgment - * for this write and does not await it on shutdown, so a bounded wait is - * currently the only way to distinguish "lost on restart" from "restarted - * before it was ever written". Measured to complete in well under 100ms on - * every checkpoint in the matrix; the margin is for slower CI disks. - */ -const RENAME_SETTLE_MS = 1_000; - -/** Outcome of one step, in the order the scenario performed them. */ -export interface ILiveCompatStepResult { - readonly name: string; - readonly outcome: 'passed' | 'failed' | 'skipped'; - readonly durationMs: number; - /** Why a step was skipped, or how it failed. Absent when it passed. */ - readonly detail?: string; -} - -/** Machine-readable result of one build's baseline. */ -export interface ILiveCompatScenarioResult { - readonly scenario: string; - readonly build: string; - /** Provenance of the launched build (commit sha, or working-tree marker). */ - readonly buildDescription?: string; - readonly outcome: 'passed' | 'failed'; - readonly durationMs: number; - readonly protocolVersion?: string; - readonly steps: readonly ILiveCompatStepResult[]; - /** - * Directory retained for post-mortem: holds the home, the user-data - * directory (and therefore the host's own logs) and the workspace, for both - * phases. Never deleted — a baseline exists to be diagnosed when it fails, - * and its state is the diagnosis. - */ - readonly diagnosticsPath: string; - /** Present when the scenario failed. */ - readonly error?: string; -} - -export interface ILiveCompatScenarioOptions { - /** Root under which the per-build diagnostics directory is created. */ - readonly diagnosticsRoot?: string; - /** Extra environment for both launches, e.g. mock-provider seeding. */ - readonly env?: Readonly>; -} - -/** Records step outcomes and their durations in performance order. */ -class StepRecorder { - private readonly _steps: ILiveCompatStepResult[] = []; - - get steps(): readonly ILiveCompatStepResult[] { - return this._steps; - } - - async run(name: string, body: () => Promise): Promise { - const startedAt = Date.now(); - try { - const result = await body(); - this._steps.push({ name, outcome: 'passed', durationMs: Date.now() - startedAt }); - return result; - } catch (error) { - this._steps.push({ name, outcome: 'failed', durationMs: Date.now() - startedAt, detail: messageOf(error) }); - throw error; - } - } - - skip(name: string, reason: string): void { - this._steps.push({ name, outcome: 'skipped', durationMs: 0, detail: reason }); - } -} - -/** - * Run the same-build restart baseline for one prepared build. - * - * Never throws for a scenario failure: a failed build is data the caller needs - * alongside the builds that passed, so the failure is reported in the returned - * result. Only a defect in the runner itself propagates. - */ -export async function runSameBuildRestartBaseline( - build: IPreparedAgentHostBuild, - options: ILiveCompatScenarioOptions = {}, -): Promise { - const startedAt = Date.now(); - const diagnosticsPath = mkdtempSync(join(options.diagnosticsRoot ?? tmpdir(), `agent-host-live-compat-${build.id}-`)); - const dirs = createPersistentDirectories(diagnosticsPath); - const recorder = new StepRecorder(); - let server: ILiveCompatServerHandle | undefined; - let client: LiveCompatAhpClient | undefined; - let protocolVersion: string | undefined; - - const launch: ILiveCompatLaunchOptions = { - serverEntry: build.serverEntry, - homeDir: dirs.homeDir, - userDataDir: dirs.userDataDir, - env: options.env, - }; - - try { - server = await recorder.run('launch', () => startLiveCompatServer(launch)); - client = await connect(server); - - const adapter = await recorder.run('initialize', async () => { - const initialize = await client!.call('initialize', { - channel: ROOT_CHANNEL, - protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], - clientId: `live-compat-${build.id}-seed`, - }, PER_CALL_TIMEOUT_MS); - protocolVersion = initialize.protocolVersion; - return createAgentHostCapabilityAdapter({ - protocolVersion: initialize.protocolVersion, - providerCapabilities: await readProviderCapabilities(client!), - }); - }); - - await recorder.run('list-empty', async () => { - const listed = await client!.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - assertEqual(listed.items?.length ?? 0, 0, 'a fresh profile must list no sessions'); - }); - - const sessionUri = await recorder.run('create-session', async () => { - const uri = `${PROVIDER}:/live-compat-${build.id}-${Date.now()}`; - await client!.call('createSession', { channel: uri, provider: PROVIDER }, PER_CALL_TIMEOUT_MS); - await client!.call('subscribe', { channel: uri }, PER_CALL_TIMEOUT_MS); - return uri; - }); - - await renameStep(recorder, client, adapter, sessionUri); - await peerChatStep(recorder, adapter); - - // The restart is only meaningful once the first process has fully exited - // and released the profile it was holding. - await recorder.run('restart', async () => { - client!.close(); - client = undefined; - await stopLiveCompatServer(server); - server = undefined; - server = await startLiveCompatServer({ - ...launch, - // The mock provider keeps its session index in memory, so a - // restarted process must be told which sessions the *provider* - // side already knows about. This mirrors what a real provider - // recovers from its own on-disk state; the host's persistence — - // which is what is under test — is not seeded and must be - // reconstructed from the retained user-data directory alone. - env: { ...options.env, VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS: sessionUri }, - }); - client = await connect(server); - await client.call('initialize', { - channel: ROOT_CHANNEL, - protocolVersions: [...LIVE_COMPAT_OFFERED_PROTOCOL_VERSIONS], - clientId: `live-compat-${build.id}-verify`, - }, PER_CALL_TIMEOUT_MS); - }); - - await recorder.run('list-restored', async () => { - const listed = await client!.call('listSessions', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - const restored = listed.items?.find(item => item.resource === sessionUri); - assertEqual(restored?.resource, sessionUri, 'the seeded session must be listed after a restart'); - if (adapter.supportsSessionRename) { - assertEqual(restored?.title, BASELINE_TITLE, 'the listed session must retain its custom title'); - } - }); - - await recorder.run('subscribe-restored', async () => { - const state = await subscribeWithRestoreRetry(client!, sessionUri); - if (adapter.supportsSessionRename) { - assertEqual(state.title, BASELINE_TITLE, 'the resubscribed session must retain its custom title'); - } - }); - - return result(build, recorder, diagnosticsPath, startedAt, protocolVersion, undefined); - } catch (error) { - return result(build, recorder, diagnosticsPath, startedAt, protocolVersion, messageOf(error)); - } finally { - client?.close(); - await stopLiveCompatServer(server).catch(() => undefined); - } -} - -async function renameStep( - recorder: StepRecorder, - client: LiveCompatAhpClient | undefined, - adapter: IAgentHostCapabilityAdapter, - sessionUri: string, -): Promise { - if (!adapter.supportsSessionRename) { - recorder.skip('rename-session', `negotiated protocol ${adapter.protocolVersion} predates client-dispatchable session/titleChanged`); - return; - } - await recorder.run('rename-session', async () => { - // `dispatchAction` is a write-ahead notification, so the readback is what - // confirms the host accepted and reduced it. - client!.notify('dispatchAction', { - channel: sessionUri, - clientSeq: 1, - action: { type: 'session/titleChanged', title: BASELINE_TITLE }, - }); - const state = await pollForTitle(client!, sessionUri, BASELINE_TITLE); - assertEqual(state.title, BASELINE_TITLE, 'the dispatched title must be observable before the restart'); - // Reducing the action and persisting it are distinct steps, and only the - // second is what a restart can recover. - // - // There is no AHP signal for the second one. `subscribe` and - // `listSessions` are both served from in-memory state, so they answer as - // soon as the reducer has run, and the catalogue write that actually - // makes the rename durable is queued fire-and-forget behind them. It is - // also not covered by the host's shutdown flush, which awaits the - // session-data and customization stores but not the catalogue store, so - // an immediate restart can genuinely lose a rename that every readable - // surface already reports as applied. - // - // A settle window is therefore the honest instrument here, and it is - // deliberately explicit rather than hidden inside a retry: the baseline - // is not asserting "renames are durable instantly", it is asserting - // "a rename that has been given time to persist survives a restart". - // Narrowing this window is a host-side change (an observable durability - // ack), not a scenario change. - await timeout(RENAME_SETTLE_MS); - }); -} - -/** - * Peer chats are recorded as an explicitly skipped step rather than omitted. - * - * The mock provider does not advertise `multipleChats`, so no build in the - * matrix can create one here — but that is a property of the *provider*, and - * stating it in the result keeps the baseline's coverage honest instead of - * silently narrower than it looks. - */ -async function peerChatStep(recorder: StepRecorder, adapter: IAgentHostCapabilityAdapter): Promise { - if (!adapter.supportsPeerChats(PROVIDER)) { - recorder.skip('peer-chat', `provider '${PROVIDER}' does not advertise multipleChats on this build`); - return; - } - // Reached only if the reference provider gains the capability; until then - // the baseline deliberately makes no peer-chat claim. - recorder.skip('peer-chat', 'peer-chat baseline coverage is owned by the cross-version matrices'); -} - -function createPersistentDirectories(root: string): { homeDir: string; userDataDir: string } { - const homeDir = join(root, 'home'); - const userDataDir = join(root, 'user-data'); - mkdirSync(homeDir, { recursive: true }); - mkdirSync(join(homeDir, '.codex'), { recursive: true }); - mkdirSync(userDataDir, { recursive: true }); - mkdirSync(join(root, 'workspace'), { recursive: true }); - return { homeDir, userDataDir }; -} - -async function connect(server: ILiveCompatServerHandle): Promise { - const client = new LiveCompatAhpClient(server.port); - await client.connect(); - return client; -} - -/** Read provider capabilities off the root snapshot, as any client would. */ -async function readProviderCapabilities(client: LiveCompatAhpClient): Promise> { - const root = await client.call('subscribe', { channel: ROOT_CHANNEL }, PER_CALL_TIMEOUT_MS); - const capabilities = new Map(); - for (const agent of root.snapshot?.state?.agents ?? []) { - capabilities.set(agent.provider, agent.capabilities ?? {}); - } - return capabilities; -} - -async function pollForTitle(client: LiveCompatAhpClient, sessionUri: string, expected: string): Promise<{ title?: string }> { - let state: { title?: string } = {}; - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); - state = subscribed.snapshot?.state ?? {}; - if (state.title === expected) { - return state; - } - await timeout(RESTORE_RETRY_DELAY_MS); - } - return state; -} - -/** Subscribe to a restored session, tolerating the transient describe window. */ -async function subscribeWithRestoreRetry(client: LiveCompatAhpClient, sessionUri: string): Promise<{ title?: string }> { - let lastError: unknown; - for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) { - try { - const subscribed = await client.call('subscribe', { channel: sessionUri }, PER_CALL_TIMEOUT_MS); - return subscribed.snapshot?.state ?? {}; - } catch (error) { - lastError = error; - await timeout(RESTORE_RETRY_DELAY_MS); - } - } - throw new Error(`could not resubscribe to ${sessionUri} within ${RESTORE_ATTEMPTS} attempts: ${messageOf(lastError)}`); -} - -function result( - build: IPreparedAgentHostBuild, - recorder: StepRecorder, - diagnosticsPath: string, - startedAt: number, - protocolVersion: string | undefined, - error: string | undefined, -): ILiveCompatScenarioResult { - return { - scenario: 'same-build-restart-baseline', - build: build.id, - buildDescription: build.description, - outcome: error === undefined ? 'passed' : 'failed', - durationMs: Date.now() - startedAt, - protocolVersion, - steps: recorder.steps, - diagnosticsPath, - ...(error === undefined ? {} : { error }), - }; -} - -function assertEqual(actual: T, expected: T, what: string): void { - if (actual !== expected) { - throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); - } -} - -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index f752c369c3a6bf..1f626bee574505 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -805,7 +805,7 @@ export async function startServer(options?: { readonly quiet?: boolean; readonly * Start the agent host server with the Copilot SDK agent with either a real or mocked LLM. * The server is started with logging enabled so the CopilotAgent is registered. */ -export async function startRealServer(options: { readonly homeDir: string; readonly serverEntry?: string; readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string; readonly codexHomeDir?: string; readonly codexAgentEnabled?: boolean; readonly mockLlm?: boolean; readonly userDataDir?: string; readonly logLevel?: string; readonly env?: NodeJS.ProcessEnv; readonly capiReplay?: { readonly fixturePath: string; readonly mode?: CapiReplayMode; readonly workDir?: string; readonly real?: boolean; readonly allowPosixCommands?: boolean; readonly allowStaleRecordedRequest?: boolean }; readonly existingCapiReplay?: CapiReplayProxy; readonly mockScenarios?: readonly IMockScenario[] }): Promise { +export async function startRealServer(options: { readonly homeDir: string; readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string; readonly codexHomeDir?: string; readonly codexAgentEnabled?: boolean; readonly mockLlm?: boolean; readonly userDataDir?: string; readonly logLevel?: string; readonly env?: NodeJS.ProcessEnv; readonly capiReplay?: { readonly fixturePath: string; readonly mode?: CapiReplayMode; readonly workDir?: string; readonly real?: boolean; readonly allowPosixCommands?: boolean; readonly allowStaleRecordedRequest?: boolean }; readonly existingCapiReplay?: CapiReplayProxy; readonly mockScenarios?: readonly IMockScenario[] }): Promise { // `capiReplay` records/replays in front of the mock LLM server, so it implies // a mock upstream even when `mockLlm` was not explicitly requested — unless // `real` is set, in which case the proxy forwards to real CAPI/GitHub. @@ -842,10 +842,7 @@ export async function startRealServer(options: { readonly homeDir: string; reado // The agent host talks to the proxy (when replaying) or directly to the mock. const capiUrl = capiReplayProxy?.url ?? mockLlmServer?.url; return new Promise((resolve, reject) => { - // Cross-version (live compatibility) runs launch a *different* build's - // compiled server entry against the same persistent dirs; everything else - // about the launch is identical. - const serverPath = options.serverEntry ?? fileURLToPath(new URL('../../node/agentHostServerMain.js', import.meta.url)); + const serverPath = fileURLToPath(new URL('../../node/agentHostServerMain.js', import.meta.url)); const args = ['--port', '0', '--without-connection-token']; if (options.claudeSdkRoot) { args.push('--claude-sdk-root', options.claudeSdkRoot); From f22e6512ab668de092e1cd22455655be36e85ce3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 27 Aug 2026 11:51:21 +0200 Subject: [PATCH 06/30] agentHost: make catalog payload rebuildable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostCatalogReconciliationService.ts | 223 +++++++++++----- .../node/agentHostCatalogSyncService.ts | 28 +- .../agentHost/node/agentHostDatabase.ts | 86 ++++++- .../platform/agentHost/node/agentService.ts | 63 ++++- .../node/agentHostCatalogListReader.test.ts | 1 + ...ntHostCatalogReconciliationService.test.ts | 241 +++++++++++++++++- .../agentHostCatalogSourceResolver.test.ts | 20 ++ .../node/agentHostCatalogSyncService.test.ts | 2 + .../test/node/agentHostDatabase.test.ts | 42 ++- .../agentHost/test/node/agentService.test.ts | 93 ++++++- .../test/node/agentSessionRegistry.test.ts | 3 + .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 14 +- 12 files changed, 705 insertions(+), 111 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts index 4feac1919f4c2c..2583f4da570226 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts @@ -11,13 +11,14 @@ import { ILogService } from '../../log/common/log.js'; import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionDataService } from '../common/sessionDataService.js'; import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, hashAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; import { AgentHostCatalogSyncResult, AgentHostCatalogSyncService, catalogLegacyMetadataMatches, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.js'; -import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase } from './agentHostDatabase.js'; +import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, 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']; @@ -43,8 +44,10 @@ export interface IAgentHostCatalogReconciliationOptions { 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 { @@ -54,9 +57,14 @@ export class AgentHostCatalogReconciliationService extends Disposable { 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 _payloadDirtyMark: Promise | undefined; + private _initialPayloadDirtyMarkPending = true; + private _lastFullVerification = 0; private _scheduledBackgroundPass = false; private _running: Promise | undefined; private _rerunRequested = false; @@ -77,8 +85,10 @@ export class AgentHostCatalogReconciliationService extends Disposable { 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 { @@ -133,11 +143,19 @@ export class AgentHostCatalogReconciliationService extends Disposable { return this._running; } + async runFullPass(): Promise { + await this._prepareFullVerification(); + return this.runPass(); + } + async whenIdle(): Promise { + await this._prepareFullVerification(); if (this._scheduledBackgroundPass) { this._scheduledPass.clear(); this._scheduledBackgroundPass = false; this.start(); + } else { + await this.runPass(); } while (this._running) { await this._running; @@ -163,7 +181,19 @@ export class AgentHostCatalogReconciliationService extends Disposable { } private async _runSinglePass(token: CancellationToken): Promise { - const sessions = [...await this._listSessions()].sort((a, b) => a.session.toString().localeCompare(b.session.toString())); + await this._ensureInitialPayloadDirtyMark(); + if (this._now() - this._lastFullVerification >= this._fullVerificationIntervalMs) { + await this._markAllPayloadsDirty(); + this._lastFullVerification = this._now(); + } + 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((a, b) => a.session.toString().localeCompare(b.session.toString())); if (sessions.length === 0) { this._storageService.delete(this._cursorStorageKey); return { outcomes: [], cursor: undefined }; @@ -171,7 +201,11 @@ export class AgentHostCatalogReconciliationService extends Disposable { const selected = this._selectBatch(sessions, this._readCursor()); const limiter = new Limiter(this._concurrency); - const outcomes = await Promise.all(selected.map(registered => limiter.queue(() => this._reconcileSession(registered, token)))); + const outcomes = await Promise.all(selected.map(registered => limiter.queue(() => this._reconcileSession( + registered, + receiptBySession.get(registered.session.toString()), + token, + )))); const cursor = selected.at(-1)?.session.toString(); if (cursor && !token.isCancellationRequested) { this._storageService.set(this._cursorStorageKey, cursor); @@ -179,7 +213,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { return { outcomes, cursor }; } - private async _reconcileSession(registered: IRegisteredSession, token: CancellationToken): Promise { + private async _reconcileSession(registered: IRegisteredSession, receipt: IAgentHostDatabaseSessionV2Receipt | undefined, token: CancellationToken): Promise { const session = registered.session; const sessionKey = session.toString(); try { @@ -195,49 +229,6 @@ export class AgentHostCatalogReconciliationService extends Disposable { return { session: sessionKey, status: 'retry', reason: 'missingDatabase' }; } try { - const snapshot = await database.object.getCatalogSyncSnapshot(); - let replayedRevision: number | undefined; - // 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 replay = await this._catalogSyncService.runExclusive( - session, - async () => { - const current = await database.object.getCatalogSyncSnapshot(); - if (current?.state !== 'pending') { - return { - session: sessionKey, - status: 'succeeded', - reason: 'pendingReplayed', - sourceRevision: current?.sourceRevision ?? snapshot.sourceRevision, - } satisfies Extract; - } - return this._replayPending(session, current, acknowledgement => database.object.acknowledgeCatalogSyncSnapshot(acknowledgement), token); - }, - ); - if (replay.status !== 'succeeded') { - if (replay.status !== 'retry' || (replay.reason !== 'staleIncarnation' && replay.reason !== 'missingCatalog')) { - return replay; - } - } else { - replayedRevision = replay.sourceRevision; - } - } - - if (token.isCancellationRequested) { - return { session: sessionKey, status: 'retry', reason: 'cancelled' }; - } - const currentSnapshot = await database.object.getCatalogSyncSnapshot(); const sourceResult = await this._resolveSource(registered); if (sourceResult.status === 'providerUnavailable') { return { session: sessionKey, status: 'retry', reason: 'providerUnavailable' }; @@ -246,31 +237,81 @@ export class AgentHostCatalogReconciliationService extends Disposable { return { session: sessionKey, status: 'retry', reason: 'cancelled' }; } const legacyMetadataMatches = await catalogLegacyMetadataMatches(database.object, sourceResult.request.legacyMetadata); - if (token.isCancellationRequested) { - return { session: sessionKey, status: 'retry', reason: 'cancelled' }; - } - - const central = await this._catalogDatabase.getSessionV2(sessionKey); const expected = encodeAgentHostCatalogPayload(sourceResult.request.data); - if (legacyMetadataMatches - && expected.ok - && currentSnapshot?.payloadHash === expected.value.payloadHash - && matchesAcknowledgedCatalogReceipt(currentSnapshot, central)) { - return replayedRevision === undefined - ? { session: sessionKey, status: 'skipped', reason: 'synchronized' } - : { session: sessionKey, status: 'succeeded', reason: 'pendingReplayed', sourceRevision: replayedRevision }; - } + return await this._catalogSyncService.runExclusive(session, async synchronize => { + const latestReceipt = await this._catalogDatabase.getSessionV2(sessionKey); + if (receipt ? latestReceipt?.payloadDirty !== receipt.payloadDirty : latestReceipt !== undefined) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + const snapshot = await database.object.getCatalogSyncSnapshot(); + let replayedRevision: number | undefined; + // 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 replay = 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 (replay.status !== 'succeeded') { + if (replay.status !== 'retry' || (replay.reason !== 'staleIncarnation' && replay.reason !== 'missingCatalog')) { + return replay; + } + } else { + replayedRevision = replay.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 this._catalogSyncService.synchronize(session, sourceResult.request); - return synchronized.status === 'acknowledged' - ? { session: sessionKey, status: 'succeeded', reason: 'synchronized', sourceRevision: synchronized.sourceRevision } - : { session: sessionKey, status: 'pending', reason: synchronized.reason, sourceRevision: synchronized.sourceRevision }; + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + 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)) { + if (!await this._markPayloadClean(sessionKey, receipt)) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + return replayedRevision === undefined + ? { session: sessionKey, status: 'skipped', reason: 'synchronized' } + : { session: sessionKey, status: 'succeeded', reason: 'pendingReplayed', sourceRevision: replayedRevision }; + } + + 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)) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + return { session: sessionKey, status: 'succeeded', reason: 'synchronized', sourceRevision: synchronized.sourceRevision }; + }); } finally { database.dispose(); } @@ -354,6 +395,48 @@ export class AgentHostCatalogReconciliationService extends Disposable { return { session, status: 'failed', reason: 'centralApplyFailed', error: result }; } + private async _markPayloadClean(session: string, receipt: IAgentHostDatabaseSessionV2Receipt | undefined): Promise { + const current = receipt ?? await this._catalogDatabase.getSessionV2(session); + if (!current) { + return false; + } + if (!receipt) { + return current.payloadDirty === 0; + } + if (current.payloadDirty === 0) { + return false; + } + return this._catalogDatabase.markSessionV2PayloadClean(session, current.payloadDirty); + } + + 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 => session.session.toString() > cursor)); const ordered = start === 0 ? sessions : [...sessions.slice(start), ...sessions.slice(0, start)]; diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts index b775b835081ff7..5e05afeb317f90 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts @@ -70,14 +70,24 @@ export class AgentHostCatalogSyncService { ) { } synchronize(session: URI, request: IAgentHostCatalogSyncRequest): Promise { - return this.runExclusive(session, () => this._synchronizeNow(session, request)); + 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: () => Promise): Promise { - return this.runExclusive(session, async () => this._synchronizeNow(session, await requestFactory())); + return this.runExclusive(session, async synchronize => { + await this._markPayloadDirty(session); + const result = await synchronize(await requestFactory()); + await this._markPayloadDirty(session); + return result; + }); } - runExclusive(session: URI, operation: () => Promise): Promise { + runExclusive(session: URI, operation: (synchronize: (request: IAgentHostCatalogSyncRequest) => Promise) => Promise): Promise { const sessionKey = session.toString(); return new Promise((resolve, reject) => { let queue = this._queues.get(sessionKey); @@ -89,7 +99,7 @@ export class AgentHostCatalogSyncService { queue.pending.push({ run: async () => { try { - resolve(await operation()); + resolve(await operation(request => this._synchronizeNow(session, request))); } catch (error) { reject(error instanceof Error ? error : new Error(String(error))); } @@ -269,4 +279,14 @@ export class AgentHostCatalogSyncService { } return result.value; } + + 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 2f315398940a6f..945aa00c3d8495 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -67,6 +67,8 @@ export interface IAgentHostDatabaseSessionV2Envelope { 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 { @@ -161,6 +163,12 @@ export interface IAgentHostDatabase extends IDisposable { 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; + /** 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; close(): Promise; } @@ -374,6 +382,7 @@ function sessionsV2BackfillKey(provider: AgentProvider, payloadVersion: number): } const sessionsV2ExcludedKeyPrefix = 'sessionsV2Excluded:'; +const sessionsV2PayloadDirtyKeyPrefix = 'sessionsV2PayloadDirty:'; function sessionsV2ExcludedProviderPrefix(provider: AgentProvider): string { return `${sessionsV2ExcludedKeyPrefix}${provider}:`; @@ -383,6 +392,10 @@ function sessionsV2ExcludedKey(provider: AgentProvider, session: string): string return `${sessionsV2ExcludedProviderPrefix(provider)}${session}`; } +function sessionsV2PayloadDirtyKey(session: string): string { + return `${sessionsV2PayloadDirtyKeyPrefix}${session}`; +} + /** Metadata key for a session's durable "explicitly deleted" tombstone. */ function tombstoneKey(session: string): string { return `sessionTombstone:${session}`; @@ -466,6 +479,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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 sessions WHERE session_uri = ?', [session]); await run(database, 'DELETE FROM sessions_v2 WHERE session_uri = ?', [session]); await exec(database, 'COMMIT'); @@ -607,6 +621,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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 sessions_v2 WHERE session_uri = ?', [exclusion.session]); await exec(database, 'COMMIT'); } catch (error) { @@ -736,6 +751,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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 exec(database, 'COMMIT'); } catch (error) { await this._rollback(database, error, `Failed to unregister mirrored runtime session ${session}`); @@ -848,6 +864,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { try { 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 exec(database, 'COMMIT'); } catch (error) { await this._rollback(database, error, `Failed to unregister sessions_v2 identity ${session}`); @@ -944,7 +961,9 @@ export class AgentHostDatabase implements IAgentHostDatabase { async getSessionV2(session: string): Promise { const row = await get( await this._ensureDatabase(), - `SELECT * + `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') @@ -958,18 +977,72 @@ export class AgentHostDatabase implements IAgentHostDatabase { } async listSessionsV2(): Promise { - const rows = await all(await this._ensureDatabase(), this._selectVerifiedSessionsV2('*'), []); + 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, external, registration_source, - session_generation, source_revision, payload_version, payload_hash, is_chat_backing`, + 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 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 upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { const isChatBacking = this._validateSessionV2Envelope(envelope); return this._transactionSequencer.queue(async () => { @@ -1154,9 +1227,16 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index b2b1498a7711cc..b65a23d519d842 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1638,9 +1638,7 @@ export class AgentService extends Disposable implements IAgentService { return { status: 'providerUnavailable' }; } const isChatBacking = await this._isChatBacking(registered.session); - const metadata = isChatBacking - ? await this._registeredSessionMetadata(agent, registered.session, registered.external) - : await this._getSessionMetadata(registered.session); + const metadata = await this._getCatalogReconciliationMetadata(agent, registered, isChatBacking); if (!metadata) { return { status: 'providerUnavailable' }; } @@ -1671,6 +1669,22 @@ export class AgentService extends Disposable implements IAgentService { }; } + 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) @@ -2334,7 +2348,7 @@ export class AgentService extends Disposable implements IAgentService { ? allRegistered.filter(entry => !hiddenExternal.has(entry.session.toString())) : allRegistered; const metadataLimiter = new Limiter(4); - let repairNeeded = false; + const repairSessions = new Set(); const results = await Promise.all(registered.map(registeredSession => metadataLimiter.queue(async (): Promise => { const { session } = registeredSession; // Idle provisional sessions stay hidden until they materialize or gain @@ -2354,7 +2368,7 @@ export class AgentService extends Disposable implements IAgentService { if (central.chatBacking) { return undefined; } - repairNeeded = true; + repairSessions.add(session.toString()); if (central.error) { this._logService.warn(`[AgentService] Failed to read central catalog row for ${session.toString()}`, central.error); } else { @@ -2371,10 +2385,14 @@ export class AgentService extends Disposable implements IAgentService { // 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 (repairNeeded && !this._store.isDisposed) { + if (repairSessions.size > 0 && !this._store.isDisposed) { this._catalogListRepair.value = disposableTimeout(() => { this._catalogListRepair.clear(); - this._catalogReconciliationService.start(); + void Promise.allSettled([...repairSessions].map(session => this._markCatalogPayloadDirty(session))).then(() => { + if (!this._store.isDisposed) { + this._catalogReconciliationService.start(); + } + }); }, 0); } const result = results.filter((s): s is IAgentSessionMetadata => s !== undefined); @@ -2644,7 +2662,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _broadcastExternalSessions = new Set(); private _sessionListReconciliation = Promise.resolve(); private _sessionListReconciliationActive = false; - private readonly _sessionListReconciliationRequests: Array = []; + private readonly _sessionListReconciliationRequests: Array<{ readonly previousMode: AgentHostExternalSessionsMode | undefined; readonly forceCatalogRefresh: boolean }> = []; /** Tracks the migrate-legacy setting so the config listener acts only on transitions. */ private _lastMigrateLegacyEnabled = false; @@ -2719,8 +2737,8 @@ export class AgentService extends Disposable implements IAgentService { } } - private _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode): void { - this._sessionListReconciliationRequests.push(previousMode); + private _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode, forceCatalogRefresh = previousMode !== undefined): void { + this._sessionListReconciliationRequests.push({ previousMode, forceCatalogRefresh }); if (this._sessionListReconciliationActive) { return; } @@ -2728,9 +2746,9 @@ export class AgentService extends Disposable implements IAgentService { this._sessionListReconciliationActive = true; try { while (this._sessionListReconciliationRequests.length > 0) { - const requestedPreviousMode = this._sessionListReconciliationRequests.shift(); + const request = this._sessionListReconciliationRequests.shift()!; try { - await this._reconcileExternalSessions(requestedPreviousMode); + await this._reconcileExternalSessions(request.previousMode, request.forceCatalogRefresh); } catch (error) { this._logService.warn('[AgentService] External session reconciliation failed', error); } @@ -2741,8 +2759,17 @@ export class AgentService extends Disposable implements IAgentService { })(); } - private async _reconcileExternalSessions(previousMode?: AgentHostExternalSessionsMode): Promise { + private async _reconcileExternalSessions(previousMode: AgentHostExternalSessionsMode | undefined, forceCatalogRefresh: boolean): 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()) { @@ -5944,12 +5971,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); @@ -5958,6 +5987,14 @@ export class AgentService extends Disposable implements IAgentService { } } + 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); + } + } + /** Reads a chat's persisted custom title (default or peer chat), if any. */ private async _readPersistedChatTitle(session: URI, chatUri: URI): Promise { const ref = await this._sessionDataService.tryOpenDatabase?.(session); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts index a899d5f5cf5a45..4e3869c71353fa 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts @@ -101,6 +101,7 @@ suite('AgentHostCatalogListReader', () => { verified: true, payload: encoded.payload, isChatBacking: catalogData.isChatBacking === true, + payloadDirty: 0, provider: registered.provider, startTime: registered.startTime, external: registered.external, diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts index 422c6ace5e1e67..2a49992a75207d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts @@ -11,7 +11,7 @@ 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 } from '../../node/agentHostCatalogReconciliationService.js'; +import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult, IAgentHostCatalogReconciliationOptions } from '../../node/agentHostCatalogReconciliationService.js'; import { AgentHostCatalogSyncService } from '../../node/agentHostCatalogSyncService.js'; import { AgentHostCatalogData, AGENT_HOST_CATALOG_PAYLOAD_VERSION } from '../../node/agentHostCatalogProjection.js'; import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabaseSessionV2Envelope } from '../../node/agentHostDatabase.js'; @@ -75,6 +75,8 @@ class TestStorageService implements IAgentHostStorageService { class RecordingCatalogDatabase extends AgentHostDatabase { upsertCalls = 0; failUpsert = false; + failUpsertCount = 0; + failMarkAll = 0; constructor() { super(':memory:'); @@ -82,18 +84,28 @@ class RecordingCatalogDatabase extends AgentHostDatabase { override async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { this.upsertCalls++; - if (this.failUpsert) { + if (this.failUpsert || this.failUpsertCount > 0) { + this.failUpsertCount = Math.max(0, this.failUpsertCount - 1); throw new Error('central unavailable'); } return super.upsertSessionV2(envelope, expectedSessionGeneration); } + + override async markAllSessionsV2PayloadsDirty(): Promise { + if (this.failMarkAll > 0) { + this.failMarkAll--; + throw new Error('dirty marker unavailable'); + } + return super.markAllSessionsV2PayloadsDirty(); + } } interface ITestHarness { readonly central: RecordingCatalogDatabase; readonly locals: Map; readonly sync: AgentHostCatalogSyncService; - createService(resolveSource?: (session: IRegisteredSession) => Promise): AgentHostCatalogReconciliationService; + readonly getDatabaseOpenAttempts: () => number; + createService(resolveSource?: (session: IRegisteredSession) => Promise, options?: IAgentHostCatalogReconciliationOptions): AgentHostCatalogReconciliationService; } suite('AgentHostCatalogReconciliationService', () => { @@ -110,6 +122,7 @@ suite('AgentHostCatalogReconciliationService', () => { }, { 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()); @@ -121,6 +134,7 @@ suite('AgentHostCatalogReconciliationService', () => { 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; }, @@ -135,10 +149,11 @@ suite('AgentHostCatalogReconciliationService', () => { central, locals, sync, + getDatabaseOpenAttempts: () => databaseOpenAttempts, createService: (resolveSource = async session => ({ status: 'available', request: { data: catalogData(session.session.path), legacyMetadata: { customTitle: session.session.path } }, - })) => store.add(new AgentHostCatalogReconciliationService( + }), options) => store.add(new AgentHostCatalogReconciliationService( sessionDataService, central, sync, @@ -146,28 +161,43 @@ suite('AgentHostCatalogReconciliationService', () => { async () => sessions, resolveSource, new NullLogService(), + options, )), }; } - test('skips only an exact sessions_v2 row, compact receipt, and canonical legacy match', async () => { + 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; - const service = harness.createService(); + 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: [{ session: 'agenthost:one', status: 'skipped', reason: 'synchronized' }], + second: [], upsertCalls: 0, + firstDatabaseOpenAttempts: 1, + finalDatabaseOpenAttempts: 1, + sourceResolutions: 1, }); }); @@ -179,22 +209,71 @@ suite('AgentHostCatalogReconciliationService', () => { const pending = await requiredLocal(harness.locals, session.session).getCatalogSyncSnapshot(); harness.central.failUpsert = false; - const report = await harness.createService().runPass(); + 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 }], + outcomes: [{ session: 'agenthost:one', status: 'retry', reason: 'superseded' }], + converged: [{ session: 'agenthost:one', status: 'skipped', reason: 'synchronized' }], after: { state: 'acknowledged', payload: undefined }, catalogTitle: 'one', }); }); + 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'); @@ -291,6 +370,150 @@ suite('AgentHostCatalogReconciliationService', () => { ]); }); + 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('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 writerResult = await harness.sync.synchronize(session.session, { + data: catalogData(currentTitle), + legacyMetadata: { customTitle: currentTitle }, + }); + releaseSource(); + const firstRepair = await 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, + firstRepair: firstRepair.outcomes, + dirtyMarker: dirty?.payloadDirty, + converged: converged.outcomes, + cachedSummary: cached && summaryOf(cached.payload), + payloadDirty: cached?.payloadDirty, + }, { + writerResult: { status: 'pending', sourceRevision: 0, reason: 'upsertFailed' }, + firstRepair: [{ session: 'agenthost:one', status: 'retry', reason: 'superseded' }], + 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'); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index aab32f9bf8c792..dd0d77134ce8c8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -11,6 +11,7 @@ import { META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '.. 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 { 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'; @@ -208,4 +209,23 @@ suite('AgentHostCatalogSourceResolver', () => { }, }); }); + + 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 index 625aa35c2d0b47..a30cebfc856645 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts @@ -146,6 +146,7 @@ suite('AgentHostCatalogSyncService', () => { 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 @@ -165,6 +166,7 @@ suite('AgentHostCatalogSyncService', () => { state: 'acknowledged', }, catalogTitle: 'one', + payloadDirty: 2, receiptMatchesCatalog: true, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 55b47e747aa492..3e1317fd57ce56 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -78,7 +78,7 @@ function createEnvelope( /** 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 }; + return { ...envelope, ...registration, isChatBacking, payloadDirty: 0 }; } async function createPublishedSessionsV2Database(path: string, version: 4 | 5 | 6): Promise { @@ -318,6 +318,46 @@ suite('AgentHostDatabase sessions_v2', () => { }); }); + 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, 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]) { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 49ebac97b52d1c..275bd77609d7ba 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -601,6 +601,28 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { 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 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); @@ -611,7 +633,7 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { if (current?.sessionGeneration !== expectedSessionGeneration) { return 'generationMismatch'; } - this._sessionsV2.set(envelope.session, { ...session, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload) }); + this._sessionsV2.set(envelope.session, { ...session, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload), payloadDirty: current?.payloadDirty ?? 1 }); return 'applied'; } @@ -847,6 +869,28 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { 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 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) { @@ -859,7 +903,7 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { 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) }); + this._sessionsV2.set(envelope.session, { ...session, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload), payloadDirty: current?.payloadDirty ?? 1 }); return 'applied'; } @@ -3330,7 +3374,7 @@ suite('AgentService (node dispatcher)', () => { const envelope = this._catalogs.get(session); const registered = await this.getSessionV2Registration(session); return envelope && registered - ? { ...registered, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload) } + ? { ...registered, ...envelope, isChatBacking: isChatBackingPayload(envelope.payload), payloadDirty: 0 } : undefined; } } @@ -4117,7 +4161,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); @@ -4126,9 +4170,8 @@ suite('AgentService (node dispatcher)', () => { agent.addSession('external-two', now); svc.registerProvider(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; @@ -4146,13 +4189,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()); + svc.registerProvider(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(); @@ -4243,7 +4316,7 @@ suite('AgentService (node dispatcher)', () => { } 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')); @@ -4264,10 +4337,10 @@ suite('AgentService (node dispatcher)', () => { })); 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({ diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index e678f18ac494a6..b1d6bd590b7ccd 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts @@ -231,6 +231,9 @@ class TestAgentHostDatabase implements IAgentHostDatabase { async getSessionV2(): Promise { return undefined; } async listSessionsV2(): Promise { return []; } async listSessionsV2Receipts(): Promise { return []; } + async markSessionV2PayloadDirty(): Promise { return undefined; } + async markAllSessionsV2PayloadsDirty(): Promise { } + async markSessionV2PayloadClean(): Promise { return false; } async upsertSessionV2(_envelope: IAgentHostDatabaseSessionV2Envelope, _expectedSessionGeneration: string | undefined): Promise { return 'missingSession'; } async close(): Promise { } 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 8e0cc5e79e1b12..d90a030de43884 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 @@ -97,13 +97,25 @@ normalizes all data before canonical serialization and hashing. Per-session databases continue to own turns, drafts, annotations, detailed changesets, and opaque provider backing required when a session or chat is opened. +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 cache: provider +state plus per-session metadata can reproduce its canonical bytes and hash. + 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. +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 From 495bfe4dec03568b1ec89c83f18fcc4ab92daf0a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 28 Aug 2026 13:34:18 +0200 Subject: [PATCH 07/30] agentHost: centralize session chat membership Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostCatalogSourceResolver.ts | 51 +++- .../agentHost/node/agentHostDatabase.ts | 153 ++++++++++++ .../agentHost/node/agentHostPeerChatStore.ts | 229 +++++++++++++++++- .../node/agentHostSessionTitleController.ts | 18 +- .../platform/agentHost/node/agentService.ts | 133 ++++++---- .../sessionTitle/sessionTitleContribution.ts | 16 +- .../agentHostCatalogSourceResolver.test.ts | 34 ++- .../test/node/agentHostDatabase.test.ts | 70 +++++- .../test/node/agentHostPeerChatStore.test.ts | 66 ++++- .../agentHost/test/node/agentService.test.ts | 159 ++++++++---- .../test/node/agentSessionRegistry.test.ts | 5 +- .../test/node/chatContributions.test.ts | 15 ++ .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 26 +- 13 files changed, 849 insertions(+), 126 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 1d65b88a5c4c3a..26f2dfece4f480 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -39,6 +39,12 @@ export interface IAgentHostCatalogSourceResolverDependencies { }; dispose(): void; }; + readonly tryOpenDatabase?: (session: URI) => Promise<{ + readonly object: { + getMetadataObject>(keys: T): Promise<{ [K in keyof T]: string | undefined }>; + }; + dispose(): void; + } | undefined>; readonly isUnpersistedChatBacking: (session: URI) => boolean; readonly worktreeProjectFromRepositoryRoot: (repositoryRoot: string | undefined) => { readonly uri: URI; readonly displayName: string } | undefined; } @@ -78,6 +84,20 @@ export class AgentHostCatalogSourceResolver { ref.dispose(); } const metadata = { ...persisted, ...metadataOverrides }; + const chatMetadata = new Map(await Promise.all(state.chats.map(async chat => { + const ref = await this._dependencies.tryOpenDatabase?.(URI.parse(chat.uri)); + if (!ref) { + return [chat.uri, undefined] as const; + } + try { + return [chat.uri, await ref.object.getMetadataObject({ + [SESSION_CUSTOM_TITLE_KEY]: true, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: true, + })] as const; + } finally { + ref.dispose(); + } + }))); const title = (preferPersistedMetadata ? metadata[SESSION_CUSTOM_TITLE_KEY] : metadataOverrides[SESSION_CUSTOM_TITLE_KEY]) ?? state.title ?? ''; const titleSource = normalizeCatalogTitleSource(metadata[SESSION_CUSTOM_TITLE_SOURCE_KEY]); const persistedMultiRoot = metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined @@ -163,14 +183,29 @@ export class AgentHostCatalogSourceResolver { workingDirectories: state.workingDirectories, changes, _meta: Object.keys(meta).length > 0 ? meta : undefined, - chats: state.chats.map((chat, order) => ({ - uri: chat.uri, - order, - kind: chat.kind, - summary: (preferPersistedMetadata ? metadata[customChatTitleMetadataKey(chat.uri)] : metadataOverrides[customChatTitleMetadataKey(chat.uri)]) || chat.title || undefined, - titleSource: normalizeCatalogTitleSource(metadata[customChatTitleSourceMetadataKey(chat.uri)]), - origin: chat.origin, - })), + chats: state.chats.map((chat, order) => { + const local = chatMetadata.get(chat.uri); + const summary = preferPersistedMetadata + ? local?.[SESSION_CUSTOM_TITLE_KEY] + || metadata[customChatTitleMetadataKey(chat.uri)] + || chat.title + || undefined + : metadataOverrides[customChatTitleMetadataKey(chat.uri)] + || chat.title + || local?.[SESSION_CUSTOM_TITLE_KEY] + || undefined; + const titleSource = preferPersistedMetadata + ? local?.[SESSION_CUSTOM_TITLE_SOURCE_KEY] ?? metadata[customChatTitleSourceMetadataKey(chat.uri)] + : metadataOverrides[customChatTitleSourceMetadataKey(chat.uri)] ?? local?.[SESSION_CUSTOM_TITLE_SOURCE_KEY] ?? metadata[customChatTitleSourceMetadataKey(chat.uri)]; + return { + uri: chat.uri, + order, + kind: chat.kind, + summary, + titleSource: normalizeCatalogTitleSource(titleSource), + origin: chat.origin, + }; + }), }; const legacyMetadata: Record = { ...metadataOverrides, diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index b57a7e828e417e..c86bb2acf83a3d 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -78,6 +78,20 @@ export interface IAgentHostDatabaseSessionV2 extends IAgentHostDatabaseSessionV2 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 chats: readonly IAgentHostDatabaseSessionChat[]; +} + export type AgentHostDatabaseSessionV2UpsertResult = 'applied' | 'replayed' | 'stale' | 'conflict' | 'generationMismatch' | 'missingSession' | 'tombstoned'; export interface IAgentHostDatabase extends IDisposable { @@ -175,6 +189,12 @@ export interface IAgentHostDatabase extends IDisposable { /** 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 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): Promise; close(): Promise; } @@ -347,6 +367,26 @@ const migrations = [ 'UPDATE sessions_v2 SET modified_time = start_time', ].join(';\n'), }, + { + version: 11, + sql: [ + `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'), + }, ] as const; function openDatabase(path: string): Promise { @@ -501,6 +541,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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 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) { @@ -667,6 +708,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { JSON.stringify({ reason: exclusion.reason, fingerprint: exclusion.fingerprint }), ]); await run(database, 'DELETE FROM metadata WHERE key = ?', [sessionsV2PayloadDirtyKey(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'); } catch (error) { @@ -795,6 +837,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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)]); @@ -910,6 +953,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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)]); @@ -1091,6 +1135,98 @@ export class AgentHostDatabase implements IAgentHostDatabase { }); } + async getSessionChatCatalog(session: string): Promise { + const rows = await all(await this._ensureDatabase(), `SELECT + catalog.revision, + catalog.legacy_mirrored_revision, + 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`, [session]); + const catalog = rows[0]; + if (!catalog) { + return undefined; + } + return { + revision: catalog.revision as number, + legacyMirroredRevision: catalog.legacy_mirrored_revision as number, + 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 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 undefined; + } + 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 revision; + } catch (error) { + return this._rollback(database, error, `Failed to replace the chat catalog for ${session}`); + } + }); + } + + async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number): 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 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]); + return row?.revision === expectedRevision && row.legacy_mirrored_revision === expectedRevision; + }); + } + async upsertSessionV2(envelope: IAgentHostDatabaseSessionV2Envelope, expectedSessionGeneration: string | undefined): Promise { const isChatBacking = this._validateSessionV2Envelope(envelope); return this._transactionSequencer.queue(async () => { @@ -1245,6 +1381,23 @@ export class AgentHostDatabase implements IAgentHostDatabase { } } + 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 diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index 46754fd2029b08..69cb8d27857892 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -10,8 +10,12 @@ import { ISessionDataService } from '../common/sessionDataService.js'; import { ChatOrigin } from '../common/state/protocol/state.js'; import { isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; import { fromCatalogChatOrigin, toCatalogJsonValue } from './agentHostCatalogSourceResolver.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'; export interface IPersistedPeerChat { readonly uri: string; @@ -25,14 +29,78 @@ export class AgentHostPeerChatStore { private readonly _writes = new Map>(); constructor( + private readonly _database: IAgentHostDatabase, private readonly _sessionDataService: ISessionDataService, private readonly _logService: ILogService, ) { } + async tryRead(session: URI): Promise { + return this._readCentral(session, true); + } + + /** Imports membership changed by an older build, then returns central authority. */ + async reconcileLegacy(session: URI): Promise { + let result: IPersistedPeerChat[] | undefined; + await this._enqueue(session, async () => { + while (true) { + const catalog = await this._database.getSessionChatCatalog(session.toString()); + const legacy = await this.tryReadLegacy(session); + if (!catalog) { + if (legacy === undefined) { + return; + } + if (!await this._replaceCentral(session, legacy, undefined)) { + continue; + } + result = legacy; + return; + } + const central = this._entriesFromCatalog(catalog.chats); + if (catalog.legacyMirroredRevision !== catalog.revision) { + try { + await this._publishCompatibilityState(session, central, catalog.revision); + } catch (error) { + this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); + } + result = central; + return; + } + if (legacy !== undefined && JSON.stringify(legacy) !== JSON.stringify(central)) { + if (!await this._replaceCentral(session, legacy, catalog.revision)) { + continue; + } + result = legacy; + return; + } + const local = await Promise.all(central.map(entry => this._readChatMetadata(entry))); + if (JSON.stringify(local) !== JSON.stringify(central) && !await this._replaceCentral(session, local, catalog.revision)) { + continue; + } + 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 tryRead(session: URI, batched = false): Promise { + async tryReadLegacy(session: URI, batched = false): Promise { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return undefined; @@ -85,11 +153,15 @@ export class AgentHostPeerChatStore { } 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(); const previous = this._writes.get(key) ?? Promise.resolve(); const next = previous .catch(() => { /* a failed prior write must not block later ones */ }) - .then(() => this._applyWrite(session, mutate)); + .then(operation); const clear = () => { if (this._writes.get(key) === tracked) { this._writes.delete(key); @@ -104,22 +176,153 @@ export class AgentHostPeerChatStore { } private async _applyWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { - const ref = this._sessionDataService.openDatabase(session); + while (true) { + const catalog = await this._database.getSessionChatCatalog(session.toString()); + const central = catalog ? this._entriesFromCatalog(catalog.chats) : undefined; + const legacy = !catalog || catalog.legacyMirroredRevision === catalog.revision + ? await this.tryReadLegacy(session) + : undefined; + const current = legacy ?? central ?? []; + const updated = this._parse(session, JSON.stringify(mutate(current))); + if (await this._replaceCentral(session, updated, catalog?.revision)) { + return; + } + } + } + + private async _replaceCentral(session: URI, updated: readonly IPersistedPeerChat[], expectedRevision: number | undefined): Promise { + const revision = await this._database.replaceSessionChatCatalog(session.toString(), updated.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 } : {}), + })), expectedRevision); + if (revision === undefined) { + return false; + } try { - let current: IPersistedPeerChat[] = []; - try { - const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); - if (raw !== undefined) { - current = this._parse(session, raw); - } - } catch (error) { - this._logService.warn(`[AgentService] Replacing malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(error)}`); + await this._publishCompatibilityState(session, updated, revision); + } catch (error) { + this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); + } + return true; + } + + private async _publishCompatibilityState(session: URI, initialEntries: readonly IPersistedPeerChat[], initialRevision: number): Promise { + let entries = initialEntries; + let revision = initialRevision; + while (true) { + await Promise.all(entries.map(entry => this._writeChatMetadata(entry))); + const current = await this._database.getSessionChatCatalog(session.toString()); + if (!current) { + return; } - const updated = this._parse(session, JSON.stringify(mutate(current))); - await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated)); + if (current.revision !== revision) { + entries = this._entriesFromCatalog(current.chats); + revision = current.revision; + continue; + } + if (await this._writeLegacyMirror(session, entries, revision)) { + 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 () => { + const catalog = await this._database.getSessionChatCatalog(session.toString()); + if (!catalog || catalog.legacyMirroredRevision === catalog.revision) { + return; + } + const entries = this._entriesFromCatalog(catalog.chats); + await this._publishCompatibilityState(session, entries, catalog.revision); + }); + } + + private async _writeLegacyMirror(session: URI, entries: readonly IPersistedPeerChat[], revision: number): Promise { + const ref = this._sessionDataService.openDatabase(session); + try { + await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(entries)); } finally { ref.dispose(); } + return this._database.markSessionChatCatalogLegacyMirrored(session.toString(), revision); + } + + 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(toCatalogJsonValue(parsed)); + } + + private _stringifyOrigin(origin: ChatOrigin): string { + const value = toCatalogJsonValue(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): IPersistedPeerChat[] { diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index ab933dbf73bce2..8bbdbe93f8d81e 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -253,16 +253,32 @@ 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); 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. */ diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 10e06574f047cd..40c64e213a9849 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -96,7 +96,7 @@ import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SU 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, toCatalogJsonValue } from './agentHostCatalogSourceResolver.js'; -import { AgentHostPeerChatStore, IPersistedPeerChat, PEER_CHATS_METADATA_KEY } from './agentHostPeerChatStore.js'; +import { AgentHostPeerChatStore, CHAT_PROVIDER_DATA_METADATA_KEY, IPersistedPeerChat } from './agentHostPeerChatStore.js'; import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; /** @@ -605,10 +605,11 @@ export class AgentService extends Disposable implements IAgentService { this._catalogSyncService = new AgentHostCatalogSyncService(this._sessionDataService, this._orchestratorDatabase, this._logService); this._catalogSourceResolver = new AgentHostCatalogSourceResolver({ openDatabase: session => this._sessionDataService.openDatabase(session), + tryOpenDatabase: session => this._sessionDataService.tryOpenDatabase(session), isUnpersistedChatBacking: session => this._unpersistedChatBackings.has(session.toString()), worktreeProjectFromRepositoryRoot, }); - this._peerChatStore = new AgentHostPeerChatStore(this._sessionDataService, this._logService); + this._peerChatStore = new AgentHostPeerChatStore(this._orchestratorDatabase, this._sessionDataService, this._logService); this._sessionsV2MigrationService = new AgentHostSessionsV2MigrationService( this._orchestratorDatabase, this._sessionDataService, @@ -1346,6 +1347,10 @@ 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, 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, @@ -3361,6 +3366,12 @@ export class AgentService extends Disposable implements IAgentService { 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, @@ -3492,6 +3503,7 @@ export class AgentService extends Disposable implements IAgentService { } await this._peerChatStore.remove(session, chat); await this._clearChatDraft(session, chat); + await this._sessionDataService.deleteSessionData(chat); const state = this._stateManager.getSessionState(sessionKey); if (state) { await this._persistOrderedListVisibleSessionState( @@ -3635,10 +3647,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) { @@ -3648,6 +3661,7 @@ export class AgentService extends Disposable implements IAgentService { if (firstError !== undefined) { throw firstError; } + return chats; } /** @@ -4211,10 +4225,15 @@ export class AgentService extends Disposable implements IAgentService { // 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); const provider = this._providerService.getProviderForSession(session); + let chatsToDelete = this._orderSessionChatsForTeardown(session, [ + ...sessionChats.map(chat => chat.resource), + ...(persistedPeerChats?.map(chat => chat.uri) ?? []), + ]); if (provider) { - await this._disposeSession(provider, session); + chatsToDelete = [...await this._disposeSession(provider, session)]; } if (!isEphemeral) { await this._retryRegistryMutation( @@ -4230,6 +4249,9 @@ export class AgentService extends Disposable implements IAgentService { 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. // @@ -5668,7 +5690,6 @@ export class AgentService extends Disposable implements IAgentService { // up-front tombstone would, before any state-manager mutation. throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); } - const centralChatCatalog = await this._readCentralChatCatalog(session); this._invalidateSessionList(); this._stateManager.restoreSession(summary, mergedTurns, { draft: restoredDraft, defaultChatTitle }); if (adoptionListVisible) { @@ -5704,7 +5725,7 @@ export class AgentService extends Disposable implements IAgentService { // Register persisted peer-chat catalog metadata. Their provider backings // and histories are restored when a peer chat is first requested. - promises.push(this._restorePeerChats(agent, session, centralChatCatalog ?? false)); + promises.push(this._restorePeerChats(agent, session)); // Register the static changeset URIs and reseed them from any // persisted file lists in the batched metadata read. The catalogue @@ -5769,33 +5790,30 @@ export class AgentService extends Disposable implements IAgentService { }; } - /** - * Restores persistent peer membership from the verified central catalog and - * merges cooling-period legacy membership before updating the projection. - */ - private async _restorePeerChats(agent: IAgent, session: URI, centralChatCatalog?: readonly ICatalogChat[] | false): Promise { - const central = centralChatCatalog === false ? undefined : centralChatCatalog ?? await this._readCentralChatCatalog(session); - if (central) { - const persisted = await this._peerChatStore.tryRead(session, true); - if (persisted === undefined) { - await this._migrateLegacyPeerChats(agent, session); - } else { - await this._restorePeerChatsFromCatalog(session, persisted); - } - await this._persistOrderedListVisibleSessionState(session, {}); - return; - } - const persisted = await this._peerChatStore.tryRead(session); - if (persisted !== undefined) { - await this._restorePeerChatsFromCatalog(session, persisted); - await this._persistOrderedListVisibleSessionState(session, {}); - return; - } - await this._migrateLegacyPeerChats(agent, session); + /** Restores authoritative central peer membership after importing cooling-period legacy changes. */ + private async _restorePeerChats(agent: IAgent, session: URI): Promise { + const entries = await this._readOrMigrateLegacyPeerChatCatalog(agent, session); + await this._restorePeerChatsFromCatalog(session, entries, await this._readCachedChatCatalog(session)); await this._persistOrderedListVisibleSessionState(session, {}); } 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; @@ -5819,22 +5837,21 @@ export class AgentService extends Disposable implements IAgentService { })); } - /** - * One-time migration for sessions without central chat membership: enumerate - * the agent's legacy `*.chats` - * ({@link IAgent.listLegacyChatBackings}), register them via the same path as the - * central catalog, then retain {@link PEER_CHATS_METADATA_KEY} for cooling. - */ - private async _migrateLegacyPeerChats(agent: IAgent, session: URI): Promise { - const entries = await this._readOrMigrateLegacyPeerChatCatalog(agent, session); - await this._restorePeerChatsFromCatalog(session, entries); - } - private async _readOrMigrateLegacyPeerChatCatalog(agent: IAgent, session: URI): Promise { - const persisted = await this._peerChatStore.tryRead(session); + const persisted = await this._peerChatStore.reconcileLegacy(session); if (persisted !== undefined) { return persisted; } + const cached = await this._readCentralChatCatalog(session); + if (cached?.some(chat => chat.kind === 'peer')) { + const peers = cached.filter(chat => chat.kind === 'peer').map(chat => ({ + uri: chat.uri, + ...(chat.origin !== undefined ? { origin: chat.origin } : {}), + ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), + })); + await this._peerChatStore.replace(session, peers); + return peers; + } const legacy = await agent.listLegacyChatBackings?.(session) ?? []; const entries: IPersistedPeerChat[] = legacy.map(chat => ({ uri: chat.uri.toString(), @@ -5849,7 +5866,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 { @@ -5858,10 +5875,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) { @@ -6062,15 +6080,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))); @@ -6081,6 +6110,18 @@ export class AgentService extends Disposable implements IAgentService { } private async _readDefaultChatProviderData(session: URI): Promise { + 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(); + } + } const ref = await this._sessionDataService.tryOpenDatabase?.(session); if (!ref) { return undefined; diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts index 20398051940279..87ec411cf501b3 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts @@ -50,6 +50,8 @@ 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); @@ -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/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index 8465b288dc36b5..6bd605b6158092 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -79,7 +79,7 @@ function persistedMetadata(): Readonly> { }; } -function createResolver(metadata: Readonly>, unpersistedBacking = false): AgentHostCatalogSourceResolver { +function createResolver(metadata: Readonly>, unpersistedBacking = false, chatMetadata?: Readonly>): AgentHostCatalogSourceResolver { return new AgentHostCatalogSourceResolver({ openDatabase: () => ({ object: { @@ -88,6 +88,13 @@ function createResolver(metadata: Readonly>, unpersistedB }, dispose: () => { }, }), + tryOpenDatabase: async () => chatMetadata ? ({ + object: { + getMetadataObject: async >(keys: T): Promise<{ [K in keyof T]: string | undefined }> => + Object.fromEntries(Object.keys(keys).map(key => [key, chatMetadata[key]])) as { [K in keyof T]: string | undefined }, + }, + dispose: () => { }, + }) : undefined, isUnpersistedChatBacking: () => unpersistedBacking, worktreeProjectFromRepositoryRoot: root => root ? { uri: URI.parse(root), displayName: 'Persisted worktree' } : undefined, }); @@ -96,6 +103,31 @@ function createResolver(metadata: Readonly>, unpersistedB suite('AgentHostCatalogSourceResolver', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('prefers chat-local titles over the downgrade-compatible session mirror', async () => { + const result = await createResolver(persistedMetadata(), false, { + [SESSION_CUSTOM_TITLE_KEY]: 'Chat-local title', + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + }).buildCatalogSyncRequest(session, sourceState(), {}, true); + + assert.deepStrictEqual(result.data.chats, [{ + uri: chat, + order: 0, + kind: 'default', + summary: 'Chat-local title', + titleSource: 'user', + origin: { kind: ChatOriginKind.Fork, chat: 'agenthost-chat:source/default', turnId: 'turn-1' }, + }]); + }); + + test('prefers live chat titles over stale chat-local metadata during live synchronization', async () => { + const result = await createResolver(persistedMetadata(), false, { + [SESSION_CUSTOM_TITLE_KEY]: 'Stale chat-local title', + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + }).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(), { diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 728a8359b5a766..1e8d6bb02001cc 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -175,7 +175,7 @@ suite('AgentHostDatabase sessions_v2', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('creates the single-table schema without changing the legacy registry', async () => { + 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', { @@ -211,8 +211,8 @@ suite('AgentHostDatabase sessions_v2', () => { sessionV2Columns: sessionV2Columns.map(row => row.name), sessionV2ForeignKeys, }, { - version: [{ user_version: 10 }], - tables: ['metadata', 'sessions', 'sessions_v2'], + version: [{ user_version: 11 }], + 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', @@ -226,7 +226,67 @@ suite('AgentHostDatabase sessions_v2', () => { } }); - test('upgrades published v4 through v6 rows through v8 and invalidates old projections', async () => { + 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 firstRevision = 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 (firstRevision === undefined) { + throw new Error('Expected the initial chat catalog write to succeed'); + } + const first = await database.getSessionChatCatalog(session); + const firstAcknowledged = await database.markSessionChatCatalogLegacyMirrored(session, firstRevision); + const secondRevision = await database.replaceSessionChatCatalog(session, [ + { chat: 'ahp-chat://second', order: 0, inheritedTurnId: 'turn-1' }, + ], firstRevision); + const conflictingRevision = await database.replaceSessionChatCatalog(session, [], firstRevision); + const staleAcknowledgement = await database.markSessionChatCatalogLegacyMirrored(session, firstRevision); + const second = await database.getSessionChatCatalog(session); + + assert.deepStrictEqual({ + before, + firstRevision, + first, + firstAcknowledged, + secondRevision, + conflictingRevision, + staleAcknowledgement, + 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: undefined, + staleAcknowledgement: false, + second: { + revision: 2, + legacyMirroredRevision: 1, + chats: [ + { chat: 'ahp-chat://second', order: 0, inheritedTurnId: 'turn-1' }, + ], + }, + }); + }); + + 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`); @@ -258,7 +318,7 @@ suite('AgentHostDatabase sessions_v2', () => { assert.deepStrictEqual(results, [4, 5, 6].map(version => ({ version, - schemaVersion: [{ user_version: 10 }], + schemaVersion: [{ user_version: 11 }], foreignKeys: [], published: undefined, directLegacy: undefined, diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index 7107b28fad0858..bfb868835f212c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { ChatOriginKind } from '../../common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js'; +import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentHostPeerChatStore, PEER_CHATS_METADATA_KEY } from '../../node/agentHostPeerChatStore.js'; import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; @@ -26,8 +27,23 @@ const origin = { 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): AgentHostPeerChatStore { - return new AgentHostPeerChatStore(createSessionDataService(database), new NullLogService()); + return new AgentHostPeerChatStore(orchestrator, createSessionDataService(database), new NullLogService()); } test('heals malformed metadata on the next write', async () => { @@ -35,7 +51,7 @@ suite('AgentHostPeerChatStore', () => { const store = createStore(database); await database.setMetadata(PEER_CHATS_METADATA_KEY, '{"not":"an array"}'); - const before = await store.tryRead(session); + const before = await store.tryReadLegacy(session); await store.upsert(session, first, 'provider-data', { kind: ChatOriginKind.User }); assert.deepStrictEqual({ @@ -70,7 +86,7 @@ suite('AgentHostPeerChatStore', () => { }, ])); - assert.deepStrictEqual(await store.tryRead(session), [ + assert.deepStrictEqual(await store.tryReadLegacy(session), [ { uri: first.toString(), providerData: 'first', origin }, { uri: third.toString(), @@ -103,6 +119,26 @@ suite('AgentHostPeerChatStore', () => { ]); }); + 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); @@ -129,4 +165,28 @@ suite('AgentHostPeerChatStore', () => { raw: '[]', }); }); + + 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' }], + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index eff17264d35c5d..3db6f3b26bc459 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -47,7 +47,8 @@ import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../co import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; -import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; +import { CHAT_PROVIDER_DATA_METADATA_KEY } from '../../node/agentHostPeerChatStore.js'; import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, type AgentHostCatalogData } from '../../node/agentHostCatalogProjection.js'; import { AgentSessionRegistry, type IRegisteredSession } from '../../node/agentSessionRegistry.js'; import { AgentHostManagementService } from '../../node/agentHostManagementService.js'; @@ -312,6 +313,7 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { 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; readonly externalUpdates: { session: string; external: boolean }[] = []; @@ -373,6 +375,7 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { 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 { @@ -511,6 +514,7 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._beforeWrite(); this._sessionV2Registrations.delete(session); this._sessionsV2.delete(session); + this._sessionChats.delete(session); this._sessions.delete(session); this._agentMergeEnabled.delete(session); } @@ -652,6 +656,26 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { 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 { + const current = this._sessionChats.get(session); + if (current?.revision !== expectedRevision) { + return undefined; + } + const revision = (current?.revision ?? 0) + 1; + this._sessionChats.set(session, { revision, legacyMirroredRevision: current?.legacyMirroredRevision ?? 0, chats: [...chats] }); + return revision; + } + async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number): Promise { + const current = this._sessionChats.get(session); + if (!current || current.revision !== expectedRevision) { + return false; + } + this._sessionChats.set(session, { ...current, legacyMirroredRevision: expectedRevision }); + return true; + } async close(): Promise { } dispose(): void { } @@ -675,6 +699,7 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { 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; @@ -704,6 +729,7 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { this._sessionV2Registrations.delete(session); this._sessionsV2.delete(session); this._agentMergeEnabled.delete(session); + this._sessionChats.delete(session); } async updateSessionExternal(): Promise { } @@ -845,6 +871,7 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { 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 { @@ -933,6 +960,26 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { 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 { + const current = this._sessionChats.get(session); + if (current?.revision !== expectedRevision) { + return undefined; + } + const revision = (current?.revision ?? 0) + 1; + this._sessionChats.set(session, { revision, legacyMirroredRevision: current?.legacyMirroredRevision ?? 0, chats: [...chats] }); + return revision; + } + async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number): Promise { + const current = this._sessionChats.get(session); + if (!current || current.revision !== expectedRevision) { + return false; + } + this._sessionChats.set(session, { ...current, legacyMirroredRevision: expectedRevision }); + return true; + } async close(): Promise { } dispose(): void { } @@ -3455,7 +3502,7 @@ suite('AgentService (node dispatcher)', () => { // of the top-level list. class FailingProviderDataDatabase extends TestSessionDatabase { override async setMetadata(key: string, value: string): Promise { - if (key === 'defaultChatProviderData') { + if (key === CHAT_PROVIDER_DATA_METADATA_KEY) { throw new Error('provider data write failed'); } return super.setMetadata(key, value); @@ -3480,7 +3527,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: [], @@ -3539,7 +3586,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 () => { @@ -3616,7 +3663,7 @@ suite('AgentService (node dispatcher)', () => { registryWriteAttempts: 3, registeredSessions: [], hasState: false, - deleteSessionDataCalls: 1, + deleteSessionDataCalls: 2, removeWorktreeCalls: 1, }); }); @@ -3696,7 +3743,7 @@ suite('AgentService (node dispatcher)', () => { }; } - function createExternalSessionService(sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase, copilotApiService?: ICopilotApiService): AgentService { + function createExternalSessionService(sessionDataService = createPerSessionDataService().service, orchestratorDatabase?: IAgentHostDatabase, copilotApiService?: ICopilotApiService): AgentService { return disposables.add(createTestAgentService( new NullLogService(), fileService, @@ -3923,8 +3970,8 @@ suite('AgentService (node dispatcher)', () => { }, { sessions: [centralSession.toString(), fallbackSession.toString()], providerMetadataCalls: [fallbackSession.toString()], - sessionDatabaseOpenSessions: [fallbackSession.toString()], - sessionDatabaseOpenCount: 2, + sessionDatabaseOpenSessions: [buildDefaultChatUri(fallbackSession), fallbackSession.toString()], + sessionDatabaseOpenCount: 3, }); }); @@ -4712,45 +4759,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 () => { @@ -13053,10 +13091,47 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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++) { @@ -13220,12 +13295,12 @@ suite('AgentService (node dispatcher)', () => { { uri: peer.toString(), title: 'Lazy Central Peer' }, ], beforeAccess: { - peerCatalogReads: 0, + peerCatalogReads: 1, legacyEnumerations: 0, peerMaterializations: 0, }, afterAccess: { - peerCatalogReads: 0, + peerCatalogReads: 1, legacyEnumerations: 0, peerMaterializations: 1, }, @@ -13301,7 +13376,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - 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; @@ -13330,14 +13405,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, }); }); @@ -14363,7 +14440,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('disposeChat preserves the chat when catalog removal fails so deletion can be retried', async () => { + 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 { @@ -14388,7 +14465,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); @@ -14398,7 +14475,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, }); @@ -14619,7 +14696,7 @@ 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 = 0; override async setMetadata(key: string, value: string): Promise { @@ -14662,15 +14739,13 @@ suite('AgentService (node dispatcher)', () => { 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); diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index 444d3c472bbae8..c5b162ea327ac9 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, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, 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 { @@ -246,6 +246,9 @@ class TestAgentHostDatabase implements IAgentHostDatabase { 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 1; } + async markSessionChatCatalogLegacyMirrored(_session: string, _expectedRevision: number): Promise { return false; } async close(): Promise { } dispose(): void { } diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 2ee49077e8568f..80b3c6032f7c93 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -1265,11 +1265,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 }], @@ -1953,4 +1957,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/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index c8ea03245b11c2..225a4be016d4a3 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 @@ -93,14 +93,30 @@ 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. Per-session -databases continue to own turns, drafts, annotations, detailed changesets, and -opaque provider backing required when a session or chat is opened. +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 cache: provider -state plus per-session metadata can reproduce its canonical bytes and hash. +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 From a502e17436b8493d9881c00aa41ab7c22a16c00f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 28 Aug 2026 19:27:57 +0200 Subject: [PATCH 08/30] agentHost: address catalog review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostSessionsV2MigrationService.ts | 4 +- .../platform/agentHost/node/agentService.ts | 200 +++++++++--------- .../agentHost/node/copilot/copilotAgent.ts | 53 ++++- .../agentHost/test/node/agentService.test.ts | 99 ++++++++- .../agentHost/test/node/copilotAgent.test.ts | 21 +- 5 files changed, 259 insertions(+), 118 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts index 1f949f37b90084..3303089048d4be 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts @@ -229,7 +229,7 @@ export class AgentHostSessionsV2MigrationService { return { status: 'incomplete' }; } - const newlyRegistered = !effectiveCandidate.current; + const shouldReportImported = !candidate.catalog; if (!effectiveCandidate.current) { const registered = await this._database.registerSessionV2(session, resolution.identity, { checkTombstone: true }); if (!registered) { @@ -243,7 +243,7 @@ export class AgentHostSessionsV2MigrationService { return result.status === 'acknowledged' ? { status: 'synchronized', - ...(newlyRegistered ? { imported: { session: candidate.session, external: resolution.external, value: resolution.value } } : {}), + ...(shouldReportImported ? { imported: { session: candidate.session, external: resolution.external, value: resolution.value } } : {}), } : { status: 'incomplete' }; } catch (error) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 40c64e213a9849..733321468dbdee 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'; @@ -405,6 +405,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()); @@ -3332,82 +3333,76 @@ 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; - const title = forkedTitle ?? options?.title; - const sessionState = this._stateManager.getSessionState(sessionKey); - if (!sessionState) { - await provider.chats.disposeChat(chat, this._chatContext(session, chat)); - 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 } : {}), - }; - // Re-creating an existing chat must not add a second membership entry: - // chat URIs are unique in the catalog. Merge into the existing entry in - // place so repeated `createChat` calls stay idempotent while preserving - // catalog order and the entry's kind (a default chat stays default). - 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); - } catch (error) { - this._catalogSyncSuppressedSessions.delete(sessionKey); - this._flushDeferredCatalogMetadataOverrides(session); - let catalogRollbackError: Error | undefined; - try { - await this._peerChatStore.remove(session, chat); - } catch (rollbackError) { - catalogRollbackError = rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)); - } - 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, ...(catalogRollbackError ? [catalogRollbackError] : []), rollbackError], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(error)}`); - } - if (catalogRollbackError) { - throw new AggregateError([error, catalogRollbackError], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(error)}`); - } - throw error; - } - - try { - this._stateManager.addChat(sessionKey, chat.toString(), { - ...(forkedTitle !== undefined ? { title: forkedTitle } : options?.title !== undefined ? { title: options.title } : {}), - ...(forkedTurns !== undefined ? { turns: forkedTurns } : {}), - ...(providerData !== undefined ? { providerData } : {}), + 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 } : {}), - ...(createResult?.inheritedTurnId !== undefined ? { inheritedTurnId: createResult.inheritedTurnId } : {}), - }); - } finally { - this._catalogSyncSuppressedSessions.delete(sessionKey); - this._flushDeferredCatalogMetadataOverrides(session); - } + }; + 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) { + let catalogRollbackError: Error | undefined; + try { + await this._peerChatStore.remove(session, chat); + } catch (rollbackError) { + catalogRollbackError = rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)); + } + try { + await provider.chats.disposeChat(chat, this._chatContext(session, chat)); + } catch (rollbackError) { + throw new AggregateError([error, ...(catalogRollbackError ? [catalogRollbackError] : []), rollbackError], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(error)}`); + } + if (catalogRollbackError) { + throw new AggregateError([error, catalogRollbackError], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(error)}`); + } + throw error; + } finally { + this._catalogSyncSuppressedSessions.delete(sessionKey); + this._flushDeferredCatalogMetadataOverrides(session); + } + return createResult; + }); this._sessionResidency.touch(session); void this._sessionResidency.reconcile(); @@ -3495,33 +3490,38 @@ export class AgentService extends Disposable implements IAgentService { const chatKey = chat.toString(); const provider = this._providerService.getProviderForSession(session); this._disposingPeerChats.add(chatKey); - this._catalogSyncSuppressedSessions.add(sessionKey); try { - await this._checkpointService.discardChatTurnStartCheckpoints(session, chat); - if (provider) { - await this._disposeChat(provider, chat); - } - await this._peerChatStore.remove(session, chat); - 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), - ); - } - 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); + try { + await this._checkpointService.discardChatTurnStartCheckpoints(session, chat); + if (provider) { + await this._disposeChat(provider, chat); + } + await this._peerChatStore.remove(session, chat); + 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), + ); + } + this._sideEffects.cancelSubagentSessions(chatKey); + this._sideEffects.clearChannelTelemetry(chatKey); + this._chatContributions.disposeChatState(chatKey); + this._stateManager.removeChat(sessionKey, chatKey); + } finally { + this._catalogSyncSuppressedSessions.delete(sessionKey); + this._flushDeferredCatalogMetadataOverrides(session); + } + }); } finally { - this._catalogSyncSuppressedSessions.delete(sessionKey); - this._flushDeferredCatalogMetadataOverrides(session); this._disposingPeerChats.delete(chatKey); } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index c1c329f81cdb75..cbc5bb92622670 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -57,13 +57,14 @@ import type { ErrorInfo } from '../../common/state/protocol/common/state.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type CustomizationEnablement, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, AuthRequiredReason, type AuthRequiredParams, type SessionAction } from '../../common/state/sessionActions.js'; import { areAdditionalWorkingDirectoriesEqual } from '../../common/state/sessionWorkingDirectories.js'; -import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_READ_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { getByokLmAgentModelId, resolveByokLmEnablement } from '../../common/agentHostByokLm.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; import { ActiveClientToolSet, structuralToolsEqual } from '../activeClientState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostManagedSettingsService } from '../agentHostManagedSettingsService.js'; import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; +import { SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../shared/persistSessionMetadata.js'; import { IAgentHostCompletions } from '../agentHostCompletions.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { applyMcpServerEnablement, buildMcpTopLevelCustomizationId, type IMcpServerRuntimeState } from '../shared/mcpCustomizationController.js'; @@ -3362,6 +3363,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' }; } @@ -3417,18 +3419,59 @@ export class CopilotAgent extends Disposable implements IAgent { // a git repo would otherwise default to worktree and show a spurious // "Creating worktree…". await this._storeSessionMetadata(session, undefined, workingDirectory, [workingDirectory], workingDirectory, project, project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, archived, /* ehcliAdopted */ true); - await this._adoptLegacyTurnUsage(session, sessionId); - // The host owns list-visible state (title / read): report it back so it - // lands in the session catalog and the per-session database together, - // instead of writing a half of it here. const listVisible = customTitle !== undefined ? { title: customTitle, titleSource: 'user' as const, isRead: true } : { isRead: true }; + const metadataRef = this._sessionDataService.openDatabase(session); + try { + await metadataRef.object.setMetadataValues({ + [AH_META_IS_READ_DB_KEY]: 'true', + ...(customTitle !== undefined ? { + [SESSION_CUSTOM_TITLE_KEY]: customTitle, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + } : {}), + }); + } finally { + metadataRef.dispose(); + } + await this._adoptLegacyTurnUsage(session, sessionId); this._logService.info(`[Copilot] Adopted legacy session ${sessionId}: project=${project ? project.uri.fsPath : '(unresolved)'} archived=${archived} customTitle=${customTitle !== undefined} worktreeBridged=${!!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; + 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` diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 3db6f3b26bc459..87e86cafe5b857 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -4679,7 +4679,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) { @@ -4688,6 +4687,7 @@ suite('AgentService (node dispatcher)', () => { notifications.push(`remove:${AgentSession.id(URI.parse(notification.session))}`); } })); + exposeListedSessions(svc, initiallyListed); agent.addSession('third', now); (svc as unknown as { _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode, forceCatalogRefresh?: boolean): void })._queueSessionListReconciliation(undefined, true); @@ -4703,7 +4703,7 @@ suite('AgentService (node dispatcher)', () => { }, { initiallyListed: ['first', 'second'], visible: ['second', 'third'], - notifications: ['add:first', 'add:third', 'remove:second', 'add:second', 'remove:first'], + notifications: ['add:first', 'add:second', 'add:third', 'remove:second', 'add:second', 'remove:first'], }); }); @@ -13213,6 +13213,101 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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; diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index e1cec821fb5fa3..cd25094bac3189 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -11681,7 +11681,7 @@ 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()); @@ -11691,11 +11691,13 @@ suite('CopilotAgent', () => { const db = await sessionDataService.tryOpenDatabase(session); const marker = await db?.object.getMetadata('agentHost.ehcliAdopted'); + const title = await db?.object.getMetadata('customTitle'); + 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, isRead }, + { reason: 'alreadyNative', marker: 'true', title: 'Legacy title', isRead: 'true' }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11977,23 +11979,25 @@ 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, retried, customTitle, isRead }, { adopted: { adopted: true, eligible: true, reason: 'adopted', - // The host owns the list-visible title; the agent - // reports it instead of writing `customTitle` itself. listVisible: { title: 'My Legacy Session', titleSource: 'user', isRead: true }, }, - customTitle: undefined, + retried: { adopted: false, eligible: false, native: true, reason: 'alreadyNative' }, + customTitle: 'My Legacy Session', + isRead: 'true', }, ); } finally { @@ -12023,8 +12027,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, isRead }, - // The host owns the read marker; the agent reports it instead of writing it. - { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { isRead: true } }, isRead: undefined }, + { adopted: { adopted: true, eligible: true, reason: 'adopted', listVisible: { isRead: true } }, isRead: 'true' }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); From 9763af2989a8c6e7958528e5bd6978b088e778f9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 1 Sep 2026 16:57:40 +0200 Subject: [PATCH 09/30] agentHost: bridge session catalog migration collision Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostDatabase.ts | 36 +++++++++++- .../test/node/agentHostDatabase.test.ts | 58 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index c86bb2acf83a3d..82030ee4407234 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -389,6 +389,40 @@ const migrations = [ }, ] as const; +async function bridgeUpstreamVersion4(database: Database, currentVersion: number): Promise { + if (currentVersion !== 4 || await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sessions_v2'`, [])) { + return currentVersion; + } + await exec(database, 'BEGIN TRANSACTION'); + try { + await exec(database, ` + 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 + ); + 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; + PRAGMA user_version = 10; + `); + await exec(database, 'COMMIT'); + return 10; + } catch (error) { + await exec(database, 'ROLLBACK'); + throw error; + } +} + function openDatabase(path: string): Promise { return new Promise((resolve, reject) => { import('@vscode/sqlite3').then(sqlite3 => { @@ -1480,7 +1514,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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 bridgeUpstreamVersion4(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/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 1e8d6bb02001cc..6e2d193dab9fd1 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -155,6 +155,27 @@ async function createPublishedSessionsV2Database(path: string, version: 4 | 5 | } } +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; @@ -311,6 +332,7 @@ suite('AgentHostDatabase sessions_v2', () => { directLegacy: await upgraded.getSession(direct), directCurrent: await upgraded.getSessionV2Registration(direct), }); + } finally { await upgraded.close(); } @@ -333,6 +355,42 @@ suite('AgentHostDatabase sessions_v2', () => { }))); }); + test('bridges the upstream v4 migration collision before applying current migrations', 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: 11 }], + tables: ['metadata', 'session_chat_catalogs', 'session_chats', 'sessions', 'sessions_v2'], + }); + } finally { + await close(rawDatabase); + } + }); + test('migrates v7 registry rows to the v8 envelope and requires payload reseeding', async () => { const path = join(temporaryDirectory!, 'agent-host-v7.db'); await createPublishedSessionsV2Database(path, 6); From 3c990ee74bfba4ae11b872726c412be87053e59f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Tue, 1 Sep 2026 23:50:12 +0200 Subject: [PATCH 10/30] agentHost: migrate catalog after first listing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentService.ts | 58 +++++++++++++--- .../agentHost/test/node/agentService.test.ts | 69 +++++++++++++++++-- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index f1f871ae10bc8d..7a22bfb7688e92 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -853,6 +853,7 @@ 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>(); async whenCatalogReconciliationIdle(): Promise { await this._catalogReconciliationService.whenIdle(); @@ -1877,20 +1878,36 @@ 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 the direct v2 import started at provider registration. - * Provider-owned discovery still surfaces later 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(): void { + for (const provider of this._providerService.getProviders()) { + if (this._backgroundInitialMigrationRetries.has(provider.id)) { + continue; + } + const retry = this._awaitInitialProviderMigrationForProvider(provider).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 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); @@ -2467,13 +2484,17 @@ export class AgentService extends Disposable implements IAgentService { 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(); + } else { + this._retryInitialProviderMigrationsInBackground(); + } // 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 @@ -2629,9 +2650,28 @@ export class AgentService extends Disposable implements IAgentService { } else { this._logService.trace(message); } + if (epoch !== this._registryEpoch) { + const currentRegistered = await this._listRegisteredSessions(); + if (!this._sameSessionRegistrations(allRegistered, currentRegistered)) { + return this._computeSessions(mode, this._registryEpoch); + } + } 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; + }); + } + /** 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; diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 6b87bbaf44bb8c..01834dad2c0a11 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -5408,7 +5408,7 @@ suite('AgentService (node dispatcher)', () => { registrations: [verified.toString(), incomplete.toString(), missing.toString()].sort(), catalog: [verified.toString(), incomplete.toString(), missing.toString()].sort(), verifiedGeneration: 'verified-generation', - missingRevisions: [0, 0], + missingRevisions: [undefined, 0], currentMarker: true, catalogCalls: 2, }); @@ -5834,6 +5834,7 @@ suite('AgentService (node dispatcher)', () => { 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; @@ -5865,7 +5866,7 @@ suite('AgentService (node dispatcher)', () => { }, incompleteIdentityAfterEnumeration: undefined, marker: true, - markerFastPass: { catalogCalls: 1, metadataCalls: 1, databaseOpens: [] }, + markerFastPass: { catalogCalls: 1, metadataCalls: 2, databaseOpens: [] }, revivedExclusion: undefined, revived: absent.toString(), }); @@ -6243,7 +6244,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; @@ -6254,18 +6255,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]); @@ -6280,6 +6293,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; @@ -6833,6 +6886,10 @@ 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({ From 65e243116a1329747eebc901af7728531282ec9f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 2 Sep 2026 01:15:03 +0200 Subject: [PATCH 11/30] agentHost: preserve titles during catalog migration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostCatalogSourceResolver.ts | 11 +- .../platform/agentHost/node/agentService.ts | 122 +++++++++++++++--- .../agentHostCatalogSourceResolver.test.ts | 18 +++ .../agentHost/test/node/agentService.test.ts | 41 +++++- 4 files changed, 168 insertions(+), 24 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 26f2dfece4f480..211a2c4f672224 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -98,8 +98,15 @@ export class AgentHostCatalogSourceResolver { ref.dispose(); } }))); - const title = (preferPersistedMetadata ? metadata[SESSION_CUSTOM_TITLE_KEY] : metadataOverrides[SESSION_CUSTOM_TITLE_KEY]) ?? state.title ?? ''; - const titleSource = normalizeCatalogTitleSource(metadata[SESSION_CUSTOM_TITLE_SOURCE_KEY]); + const defaultChat = state.chats.find(chat => chat.kind === 'default'); + const defaultChatMetadata = defaultChat ? chatMetadata.get(defaultChat.uri) : undefined; + const title = preferPersistedMetadata + ? metadata[SESSION_CUSTOM_TITLE_KEY] ?? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_KEY] ?? state.title ?? '' + : metadataOverrides[SESSION_CUSTOM_TITLE_KEY] ?? state.title ?? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_KEY] ?? ''; + const titleSource = normalizeCatalogTitleSource( + metadata[SESSION_CUSTOM_TITLE_SOURCE_KEY] + ?? (metadata[SESSION_CUSTOM_TITLE_KEY] === undefined ? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_SOURCE_KEY] : undefined), + ); const persistedMultiRoot = metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined ? parseSessionMultiRootMetadata(metadata[SESSION_META_MULTI_ROOT_KEY]) : undefined; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index dc93478b71503a..f31cb8d71a9636 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -313,6 +313,11 @@ interface ICatalogChat { 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 @@ -1531,7 +1536,7 @@ export class AgentService extends Disposable implements IAgentService { }; } - private async _legacyRegisteredSessionMetadata(registered: IRegisteredSession): Promise { + private async _legacyRegisteredSessionMetadata(registered: IRegisteredSession): Promise { const agent = this._providerService.getProvider(registered.provider); if (!agent) { return undefined; @@ -1544,21 +1549,24 @@ export class AgentService extends Disposable implements IAgentService { try { const ref = await this._sessionDataService.tryOpenDatabase(metadata.session); if (!ref) { - return sanitized; + 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, [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, [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_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_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 }; + ? { 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, [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, [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; - if (persisted.customTitle) { - updated = { ...updated, summary: persisted.customTitle }; + 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') }; @@ -1617,13 +1625,16 @@ export class AgentService extends Disposable implements IAgentService { if (worktreeProject) { updated = { ...updated, project: worktreeProject }; } - return this._changesetCoordinator.decorateListEntry(updated, persisted as Record); + 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 sanitized; + return { metadata: sanitized, persistedTitle: await this._readPersistedSessionTitle(metadata.session) }; } } @@ -1650,7 +1661,7 @@ export class AgentService extends Disposable implements IAgentService { return this._registeredSessionMetadata(agent, session, registered.external, registered); } - private _withLiveSessionMetadata(metadata: IAgentSessionMetadata, liveSummary: SessionSummary, trustLiveMultiRoot = true): 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; @@ -1660,7 +1671,7 @@ export class AgentService extends Disposable implements IAgentService { _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), @@ -2329,12 +2340,15 @@ export class AgentService extends Disposable implements IAgentService { if (external && !readSessionEhcliAdoptable(canonicalMetadata._meta) && this._isExternalSessionOlderThanMaxAge(canonicalMetadata.modifiedTime, Date.now())) { return { status: 'excluded', reason: 'staleExternal', fingerprint: String(canonicalMetadata.modifiedTime) }; } + const request = await this._buildImportedCatalogSyncRequest(provider, canonicalMetadata, external, !candidate.current && !candidate.legacy); return { status: 'ready', identity, external, - request: await this._buildImportedCatalogSyncRequest(provider, canonicalMetadata, external, !candidate.current && !candidate.legacy), - value: canonicalMetadata, + request, + value: request.data.summary === canonicalMetadata.summary + ? canonicalMetadata + : { ...canonicalMetadata, summary: request.data.summary }, }; } @@ -2570,6 +2584,7 @@ export class AgentService extends Disposable implements IAgentService { : allRegistered; const metadataLimiter = new Limiter(4); const repairSessions = new Set(); + const persistedFallbackTitles = new Map(); const results = await Promise.all(registered.map(registeredSession => metadataLimiter.queue(async (): Promise => { const { session } = registeredSession; // Idle provisional sessions stay hidden until they materialize or gain @@ -2597,7 +2612,14 @@ export class AgentService extends Disposable implements IAgentService { } try { - return await this._legacyRegisteredSessionMetadata(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; @@ -2630,10 +2652,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, false); - } - 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' @@ -3106,13 +3131,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) { @@ -3121,6 +3148,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 { diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index 6bd605b6158092..b60a68b4b01117 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -119,6 +119,24 @@ suite('AgentHostCatalogSourceResolver', () => { }]); }); + test('uses the default chat title as the 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, false, { + [SESSION_CUSTOM_TITLE_KEY]: 'Default Chat Title', + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', + }).buildCatalogSyncRequest(session, sourceState(), {}, true); + + assert.deepStrictEqual({ + summary: result.data.summary, + titleSource: result.data.titleSource, + }, { + summary: 'Default Chat Title', + titleSource: 'user', + }); + }); + test('prefers live chat titles over stale chat-local metadata during live synchronization', async () => { const result = await createResolver(persistedMetadata(), false, { [SESSION_CUSTOM_TITLE_KEY]: 'Stale chat-local title', diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 3a121f112485af..dd027f93af839b 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -4158,7 +4158,7 @@ suite('AgentService (node dispatcher)', () => { sessions: [centralSession.toString(), fallbackSession.toString()], providerMetadataCalls: [fallbackSession.toString()], sessionDatabaseOpenSessions: [buildDefaultChatUri(fallbackSession), fallbackSession.toString()], - sessionDatabaseOpenCount: 3, + sessionDatabaseOpenCount: 4, }); }); @@ -4876,6 +4876,10 @@ suite('AgentService (node dispatcher)', () => { } })); 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(previousMode?: AgentHostExternalSessionsMode, forceCatalogRefresh?: boolean): void })._queueSessionListReconciliation(undefined, true); @@ -4891,7 +4895,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'], }); }); @@ -7629,6 +7633,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 From 84ffc4d8197430b34d351100f5721ab5d80f3634 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 2 Sep 2026 15:13:37 +0200 Subject: [PATCH 12/30] agentHost: bound catalog summaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostCatalogProjection.ts | 28 +++++++-------- .../node/agentHostCatalogSourceResolver.ts | 18 ++++++++-- .../agentHostCatalogSourceResolver.test.ts | 34 ++++++++++++++++++- .../agentHost/test/node/agentService.test.ts | 29 +++++++++++++++- 4 files changed, 90 insertions(+), 19 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index 8f283fb4f7f9b5..d298a0f8f08f63 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -16,9 +16,9 @@ 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; const MAX_STRING_LENGTH = 4096; -const MAX_TITLE_LENGTH = 1024; const MAX_JSON_DEPTH = 20; const MAX_JSON_ENTRIES = 2000; @@ -195,7 +195,7 @@ const changesValidator = plainObject(vObj({ const projectValidator = plainObject(vObj({ uri: uriString(), - displayName: boundedString(MAX_TITLE_LENGTH), + displayName: boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT), })); const multiRootValidator = plainObject(vObj({ @@ -211,28 +211,28 @@ const folderPickerValidator = new RefinedValidator(plainObject(vObj({ const githubReferencesValidator = boundedArray(uriString(), AGENT_HOST_CATALOG_GITHUB_REFERENCE_LIMIT); const githubValidator = plainObject(vObj({ - owner: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), - repo: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + 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), - pullRequestBranchName: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + pullRequestBranchName: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), })); const gitValidator = plainObject(vObj({ hasGitHubRemote: vOptionalProp(vBoolean()), - branchName: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + branchName: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), isDetachedHead: vOptionalProp(vBoolean()), - baseBranchName: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), - upstreamBranchName: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + 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(MAX_TITLE_LENGTH)), - githubHeadOwner: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), - githubRepo: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + 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. */ @@ -248,7 +248,7 @@ const sourceControlValidator = new RefinedValidator(plainObject(vObj({ const artifactValidator = plainObject(vObj({ id: boundedString(), type: vEnum('pullRequest', 'issue', 'commit', 'website', 'file', 'resource'), - label: boundedString(MAX_TITLE_LENGTH), + label: boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT), isArtifact: vOptionalProp(vBoolean()), link: vOptionalProp(boundedString()), uri: vOptionalProp(boundedString()), @@ -292,7 +292,7 @@ const chatValidator = plainObject(vObj({ uri: uriString(), order: safeInteger(), kind: vEnum('default', 'peer'), - summary: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + summary: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), titleSource: vOptionalProp(vEnum('user', 'agent', 'auto')), origin: vOptionalProp(jsonValue()), })); @@ -318,7 +318,7 @@ const workingDirectoriesValidator = new RefinedValidator( export const agentHostCatalogDataValidator = plainObject(vObj({ modifiedTime: safeInteger(), - summary: vOptionalProp(boundedString(MAX_TITLE_LENGTH)), + summary: vOptionalProp(boundedString(AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT)), titleSource: vOptionalProp(vEnum('user', 'agent', 'auto')), isRead: vBoolean(), isArchived: vBoolean(), diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 211a2c4f672224..9ddc4b3ce80756 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -9,7 +9,7 @@ import { META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; import { GIT_DB_METADATA_KEYS, 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 { AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, agentHostCatalogGitValidator } from './agentHostCatalogProjection.js'; +import { AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, 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'; @@ -179,7 +179,7 @@ export class AgentHostCatalogSourceResolver { }; const data: AgentHostCatalogData = { modifiedTime: state.modifiedTime, - summary: title || undefined, + summary: toCatalogSummary(title), titleSource, isRead, isArchived, @@ -208,7 +208,7 @@ export class AgentHostCatalogSourceResolver { uri: chat.uri, order, kind: chat.kind, - summary, + summary: toCatalogSummary(summary), titleSource: normalizeCatalogTitleSource(titleSource), origin: chat.origin, }; @@ -258,6 +258,18 @@ export class AgentHostCatalogSourceResolver { } } +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 toCatalogJsonValue(value: unknown): AgentHostCatalogJsonValue | undefined { if (value === undefined) { return undefined; diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index b60a68b4b01117..2f3b282f68b9a3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -11,7 +11,7 @@ import { META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '.. 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 { encodeAgentHostCatalogPayload } from '../../node/agentHostCatalogProjection.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'; @@ -137,6 +137,38 @@ suite('AgentHostCatalogSourceResolver', () => { }); }); + 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(), false, { [SESSION_CUSTOM_TITLE_KEY]: 'Stale chat-local title', diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index dd027f93af839b..df08c4f3829ca3 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -49,7 +49,7 @@ import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; import { CHAT_PROVIDER_DATA_METADATA_KEY } from '../../node/agentHostPeerChatStore.js'; -import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, type AgentHostCatalogData } from '../../node/agentHostCatalogProjection.js'; +import { 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_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; @@ -5344,6 +5344,33 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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(); From e6bdcba2b4182876ee24eb698c4a4d8ec908200a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 2 Sep 2026 16:02:16 +0200 Subject: [PATCH 13/30] agentHost: address catalog persistence feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostCatalogSourceResolver.ts | 139 ++++++---- .../agentHost/node/agentHostDatabase.ts | 237 +++++------------- .../test/node/agentHostDatabase.test.ts | 62 ++++- 3 files changed, 197 insertions(+), 241 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 9ddc4b3ce80756..4c3a38139540dd 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -6,7 +6,7 @@ import { URI } from '../../../base/common/uri.js'; import { parseSessionArtifacts, readSessionArtifacts, SESSION_META_ARTIFACTS_KEY, stringifySessionArtifacts } from '../common/sessionArtifacts.js'; import { META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; -import { GIT_DB_METADATA_KEYS, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.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_TITLE_LENGTH_LIMIT, AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, agentHostCatalogGitValidator } from './agentHostCatalogProjection.js'; @@ -49,28 +49,69 @@ export interface IAgentHostCatalogSourceResolverDependencies { readonly worktreeProjectFromRepositoryRoot: (repositoryRoot: string | undefined) => { readonly uri: URI; readonly displayName: 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), +} 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): Promise { - const metadataKeys: Record = { - [SESSION_CUSTOM_TITLE_KEY]: true, - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 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, - [SESSION_META_MULTI_ROOT_KEY]: true, - [SESSION_META_FOLDER_PICKER_KEY]: true, - [SESSION_ARTIFACTS_KEY]: true, - [META_CHANGES_SUMMARY]: true, - [CHAT_BACKING_METADATA_KEY]: true, - [WORKTREE_META_REPOSITORY_ROOT]: true, - ...GIT_DB_METADATA_KEYS, - }; + const metadataKeys = createMetadataKeySet(sessionMetadataKeys); for (const chat of state.chats) { metadataKeys[customChatTitleMetadataKey(chat.uri)] = true; metadataKeys[customChatTitleSourceMetadataKey(chat.uri)] = true; @@ -98,73 +139,61 @@ export class AgentHostCatalogSourceResolver { ref.dispose(); } }))); + const persistedTitle = sessionMetadata.title.read(metadata); + const persistedTitleSource = sessionMetadata.titleSource.read(metadata); const defaultChat = state.chats.find(chat => chat.kind === 'default'); const defaultChatMetadata = defaultChat ? chatMetadata.get(defaultChat.uri) : undefined; const title = preferPersistedMetadata - ? metadata[SESSION_CUSTOM_TITLE_KEY] ?? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_KEY] ?? state.title ?? '' + ? persistedTitle ?? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_KEY] ?? state.title ?? '' : metadataOverrides[SESSION_CUSTOM_TITLE_KEY] ?? state.title ?? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_KEY] ?? ''; const titleSource = normalizeCatalogTitleSource( - metadata[SESSION_CUSTOM_TITLE_SOURCE_KEY] - ?? (metadata[SESSION_CUSTOM_TITLE_KEY] === undefined ? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_SOURCE_KEY] : undefined), + persistedTitleSource + ?? (persistedTitle === undefined ? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_SOURCE_KEY] : undefined), ); - const persistedMultiRoot = metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined - ? parseSessionMultiRootMetadata(metadata[SESSION_META_MULTI_ROOT_KEY]) - : undefined; + const persistedMultiRoot = sessionMetadata.multiRoot.read(metadata); const multiRoot = preferPersistedMetadata - ? (metadata[SESSION_META_MULTI_ROOT_KEY] !== undefined ? persistedMultiRoot : readSessionMultiRootMetadata(state.meta)) + ? (sessionMetadata.multiRoot.has(metadata) ? persistedMultiRoot : readSessionMultiRootMetadata(state.meta)) : readSessionMultiRootMetadata(state.meta) ?? persistedMultiRoot; - const persistedFolderPicker = metadata[SESSION_META_FOLDER_PICKER_KEY] !== undefined - ? parseSessionFolderPickerDecision(metadata[SESSION_META_FOLDER_PICKER_KEY]) - : undefined; + const persistedFolderPicker = sessionMetadata.folderPicker.read(metadata); const folderPicker = preferPersistedMetadata - ? (metadata[SESSION_META_FOLDER_PICKER_KEY] !== undefined ? persistedFolderPicker : readSessionFolderPickerDecision(state.meta)) + ? (sessionMetadata.folderPicker.has(metadata) ? persistedFolderPicker : readSessionFolderPickerDecision(state.meta)) : readSessionFolderPickerDecision(state.meta) ?? persistedFolderPicker; - const persistedArtifacts = parseSessionArtifacts(metadata[SESSION_ARTIFACTS_KEY]).artifacts; + 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 = metadata[AH_META_CREATED_BY_SESSION_DB_KEY] !== undefined - ? parseSessionCreationReference(metadata[AH_META_CREATED_BY_SESSION_DB_KEY]) - : undefined; + const persistedCreationReference = sessionMetadata.creationReference.read(metadata); const creationReference = preferPersistedMetadata - ? (metadata[AH_META_CREATED_BY_SESSION_DB_KEY] !== undefined ? persistedCreationReference : readSessionCreationReference(state.meta)) + ? (sessionMetadata.creationReference.has(metadata) ? persistedCreationReference : readSessionCreationReference(state.meta)) : readSessionCreationReference(state.meta) ?? persistedCreationReference; - const persistedGitHub = metadata[META_GITHUB_STATE] !== undefined - ? readPersistedGitHubState(metadata[META_GITHUB_STATE]) - : undefined; + const persistedGitHub = sessionMetadata.gitHub.read(metadata); const github = preferPersistedMetadata - ? (metadata[META_GITHUB_STATE] !== undefined ? persistedGitHub : readSessionGitHubState(state.meta)) + ? (sessionMetadata.gitHub.has(metadata) ? persistedGitHub : readSessionGitHubState(state.meta)) : readSessionGitHubState(state.meta) ?? persistedGitHub; - const persistedSourceControl = metadata[META_SOURCE_CONTROL_STATE] !== undefined - ? readPersistedSourceControlState(metadata[META_SOURCE_CONTROL_STATE]) - : undefined; + const persistedSourceControl = sessionMetadata.sourceControl.read(metadata); const sourceControl = preferPersistedMetadata - ? (metadata[META_SOURCE_CONTROL_STATE] !== undefined ? persistedSourceControl : readSessionSourceControlState(state.meta)) + ? (sessionMetadata.sourceControl.has(metadata) ? persistedSourceControl : readSessionSourceControlState(state.meta)) : readSessionSourceControlState(state.meta) ?? persistedSourceControl; - const persistedGit = metadata[META_GIT_STATE] !== undefined - ? readPersistedGitState(metadata[META_GIT_STATE]) - : undefined; + const persistedGit = sessionMetadata.git.read(metadata); const git = readSessionGitState(state.meta) ?? persistedGit; - const persistedWorkspaceless = metadata[AH_META_WORKSPACELESS_DB_KEY] === 'true'; + 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 - ? metadata[AH_META_IS_READ_DB_KEY] === 'true' + ? sessionMetadata.isRead.read(metadata) ?? false : stateIsRead; - const persistedArchived = metadata[AH_META_IS_ARCHIVED_DB_KEY] ?? metadata[AH_META_IS_DONE_DB_KEY]; + const persistedArchived = sessionMetadata.isArchived.read(metadata) ?? sessionMetadata.isDone.read(metadata); const isArchived = preferPersistedMetadata && persistedArchived !== undefined - ? persistedArchived === 'true' + ? persistedArchived : (state.status & SessionStatus.IsArchived) !== 0; - const persistedChanges = metadata[META_CHANGES_SUMMARY] !== undefined - ? readPersistedChanges(metadata[META_CHANGES_SUMMARY]) - : undefined; + const persistedChanges = sessionMetadata.changes.read(metadata); const changes = preferPersistedMetadata && metadata[META_CHANGES_SUMMARY] !== undefined ? persistedChanges : state.changes; - const worktreeProject = this._dependencies.worktreeProjectFromRepositoryRoot(metadata[WORKTREE_META_REPOSITORY_ROOT]); + const worktreeProject = this._dependencies.worktreeProjectFromRepositoryRoot(sessionMetadata.worktreeRepositoryRoot.read(metadata)); const ehcliAdoptable = readSessionEhcliAdoptable(state.meta); - const ehcliAdopted = readSessionEhcliAdopted(state.meta) || metadata[AH_META_EHCLI_ADOPTED_DB_KEY] === 'true'; + const ehcliAdopted = readSessionEhcliAdopted(state.meta) || sessionMetadata.ehcliAdopted.read(metadata) === true; const meta: AgentHostCatalogMetadata = { ...(multiRoot ? { [SESSION_META_MULTI_ROOT_KEY]: multiRoot } : undefined), ...(folderPicker ? { [SESSION_META_FOLDER_PICKER_KEY]: folderPicker } : undefined), @@ -186,7 +215,7 @@ export class AgentHostCatalogSourceResolver { project: worktreeProject ? { uri: worktreeProject.uri.toString(), displayName: worktreeProject.displayName } : state.project, - isChatBacking: !!metadata[CHAT_BACKING_METADATA_KEY] || this._dependencies.isUnpersistedChatBacking(session), + isChatBacking: !!sessionMetadata.chatBacking.read(metadata) || this._dependencies.isUnpersistedChatBacking(session), workingDirectories: state.workingDirectories, changes, _meta: Object.keys(meta).length > 0 ? meta : undefined, diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index 82030ee4407234..ecd0b080226734 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -198,6 +198,40 @@ export interface IAgentHostDatabase extends IDisposable { 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, @@ -226,197 +260,48 @@ const migrations = [ }, { version: 4, - sql: [ - `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_v2 (session_uri, provider, start_time, external, registration_source) - SELECT session_uri, provider, start_time, external, registration_source FROM sessions`, - ].join(';\n'), - }, - { - version: 5, - sql: 'ALTER TABLE sessions_v2 ADD COLUMN is_chat_backing INTEGER NOT NULL DEFAULT 0 CHECK (is_chat_backing IN (0, 1))', - }, - { - version: 6, - sql: 'ALTER TABLE sessions_v2 ADD COLUMN ehcli_adopted INTEGER CHECK (ehcli_adopted IN (0, 1))', - }, - { - version: 7, - sql: [ - `CREATE TABLE sessions_v2_v7 ( - session_uri TEXT PRIMARY KEY NOT NULL, - 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)), - is_chat_backing INTEGER NOT NULL DEFAULT 0 CHECK (is_chat_backing IN (0, 1)), - ehcli_adopted INTEGER CHECK (ehcli_adopted IN (0, 1)) - )`, - `INSERT INTO sessions_v2_v7 ( - 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, is_chat_backing, ehcli_adopted - ) - SELECT - 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, is_chat_backing, ehcli_adopted - FROM sessions_v2`, - 'DROP TABLE sessions_v2', - 'ALTER TABLE sessions_v2_v7 RENAME TO sessions_v2', - ].join(';\n'), - }, - { - version: 8, - sql: [ - `CREATE TABLE sessions_v2_v8 ( - 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)) - )`, - `INSERT INTO sessions_v2_v8 ( - session_uri, provider, start_time, external, registration_source, - session_generation, source_revision, payload_version, payload_hash, verified, payload, is_chat_backing - ) - SELECT - session_uri, provider, start_time, external, registration_source, - session_generation, source_revision, projection_version, source_hash, 0, NULL, is_chat_backing - FROM sessions_v2`, - 'DROP TABLE sessions_v2', - 'ALTER TABLE sessions_v2_v8 RENAME TO sessions_v2', - ].join(';\n'), - }, - { - version: 9, sql: [ 'ALTER TABLE sessions ADD COLUMN modified_time INTEGER NOT NULL DEFAULT 0', 'UPDATE sessions SET modified_time = start_time', ].join(';\n'), }, { - version: 10, - sql: [ - 'ALTER TABLE sessions_v2 ADD COLUMN modified_time INTEGER NOT NULL DEFAULT 0', - 'UPDATE sessions_v2 SET modified_time = start_time', - ].join(';\n'), - }, - { - version: 11, + version: 5, sql: [ - `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) - )`, + 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; -async function bridgeUpstreamVersion4(database: Database, currentVersion: number): Promise { - if (currentVersion !== 4 || await get(database, `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sessions_v2'`, [])) { +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; } await exec(database, 'BEGIN TRANSACTION'); try { - await exec(database, ` - 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 - ); - 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; - PRAGMA user_version = 10; - `); + const hasFinalCatalog = (currentVersion === 5 || currentVersion === 11) + && 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'`, []); + 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 10; + return 5; } catch (error) { await exec(database, 'ROLLBACK'); throw error; @@ -1514,7 +1399,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { database.serialize(); await exec(database, 'PRAGMA foreign_keys = ON'); const versionRow = await get(database, 'PRAGMA user_version', []); - const currentVersion = await bridgeUpstreamVersion4(database, (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/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 6e2d193dab9fd1..043f78c09ee2fb 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -232,7 +232,7 @@ suite('AgentHostDatabase sessions_v2', () => { sessionV2Columns: sessionV2Columns.map(row => row.name), sessionV2ForeignKeys, }, { - version: [{ user_version: 11 }], + 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: [ @@ -340,7 +340,7 @@ suite('AgentHostDatabase sessions_v2', () => { assert.deepStrictEqual(results, [4, 5, 6].map(version => ({ version, - schemaVersion: [{ user_version: 11 }], + schemaVersion: [{ user_version: 5 }], foreignKeys: [], published: undefined, directLegacy: undefined, @@ -355,7 +355,7 @@ suite('AgentHostDatabase sessions_v2', () => { }))); }); - test('bridges the upstream v4 migration collision before applying current migrations', async () => { + 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); @@ -383,7 +383,7 @@ suite('AgentHostDatabase sessions_v2', () => { 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: 11 }], + version: [{ user_version: 5 }], tables: ['metadata', 'session_chat_catalogs', 'session_chats', 'sessions', 'sessions_v2'], }); } finally { @@ -391,7 +391,7 @@ suite('AgentHostDatabase sessions_v2', () => { } }); - test('migrates v7 registry rows to the v8 envelope and requires payload reseeding', async () => { + 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); @@ -427,17 +427,59 @@ suite('AgentHostDatabase sessions_v2', () => { start_time: 6, external: 1, registration_source: 'discovery', - session_generation: 'generation-6', - source_revision: 7, - payload_version: 4, - payload_hash: 'published-hash', + session_generation: null, + source_revision: null, + payload_version: null, + payload_hash: null, verified: 0, payload: null, - is_chat_backing: 1, + 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('increments dirty markers and clears only the observed marker', async () => { database = new AgentHostDatabase(':memory:'); const session = 'session://dirty-marker'; From 4920195e451c3affac76d3971358e0d5a4d65698 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 2 Sep 2026 18:33:47 +0200 Subject: [PATCH 14/30] agentHost: reuse keyed catalog sequencer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostCatalogSyncService.ts | 47 +++---------------- 1 file changed, 6 insertions(+), 41 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts index 5e05afeb317f90..9a3725f1e185f9 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts @@ -5,6 +5,7 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { URI } from '../../../base/common/uri.js'; +import { SequencerByKey } from '../../../base/common/async.js'; import { ILogService } from '../../log/common/log.js'; import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionCatalogSyncSnapshot, ISessionDataService } from '../common/sessionDataService.js'; import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, AgentHostCatalogData, encodeAgentHostCatalogPayload, IAgentHostCatalogEncodedPayload } from './agentHostCatalogProjection.js'; @@ -22,10 +23,6 @@ export type AgentHostCatalogSyncResult = | { readonly status: 'acknowledged'; readonly sourceRevision: number } | { readonly status: 'pending'; readonly sourceRevision: number; readonly reason: AgentHostDatabaseSessionV2UpsertResult | 'upsertFailed' | 'acknowledgementSuperseded' }; -interface IQueuedOperation { - readonly run: () => Promise; -} - /** * Whether the stored catalog row is exactly the one an acknowledged local * receipt describes, so the session needs no further synchronization. @@ -54,14 +51,9 @@ export async function catalogLegacyMetadataMatches( return Object.entries(legacyMetadata).every(([key, value]) => persistedMetadata[key] === value); } -interface ISessionSyncQueue { - running: boolean; - readonly pending: IQueuedOperation[]; -} - export class AgentHostCatalogSyncService { - private readonly _queues = new Map(); + private readonly _sequencer = new SequencerByKey(); constructor( private readonly _sessionDataService: ISessionDataService, @@ -88,37 +80,10 @@ export class AgentHostCatalogSyncService { } runExclusive(session: URI, operation: (synchronize: (request: IAgentHostCatalogSyncRequest) => Promise) => Promise): Promise { - const sessionKey = session.toString(); - return new Promise((resolve, reject) => { - let queue = this._queues.get(sessionKey); - if (!queue) { - queue = { running: false, pending: [] }; - this._queues.set(sessionKey, queue); - } - - queue.pending.push({ - run: async () => { - try { - resolve(await operation(request => this._synchronizeNow(session, request))); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - }, - }); - - if (!queue.running) { - queue.running = true; - void this._drain(sessionKey, queue); - } - }); - } - - private async _drain(sessionKey: string, queue: ISessionSyncQueue): Promise { - while (queue.pending.length > 0) { - await queue.pending.shift()!.run(); - } - queue.running = false; - this._queues.delete(sessionKey); + return this._sequencer.queue( + session.toString(), + () => operation(request => this._synchronizeNow(session, request)), + ); } private async _synchronizeNow(session: URI, request: IAgentHostCatalogSyncRequest): Promise { From 6966b9068094bf30f21cbe54811a2ef1a8806ed7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 2 Sep 2026 22:13:28 +0200 Subject: [PATCH 15/30] agentHost: harden central catalog synchronization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostCatalogProjection.ts | 11 ++ .../node/agentHostCatalogSourceResolver.ts | 18 ++ .../agentHost/node/agentHostDatabase.ts | 74 ++++++-- .../agentHost/node/agentHostPeerChatStore.ts | 110 +++++++++-- .../platform/agentHost/node/agentService.ts | 79 +++++--- .../node/agentHostCatalogProjection.test.ts | 35 ++++ .../agentHostCatalogSourceResolver.test.ts | 28 +++ .../test/node/agentHostDatabase.test.ts | 21 +- .../test/node/agentHostPeerChatStore.test.ts | 83 ++++++++ .../agentHost/test/node/agentService.test.ts | 179 ++++++++++++++++-- .../test/node/agentSessionRegistry.test.ts | 1 + 11 files changed, 562 insertions(+), 77 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index d298a0f8f08f63..eb37061370a1df 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -8,6 +8,7 @@ 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'; @@ -217,6 +218,8 @@ const githubValidator = plainObject(vObj({ 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)), })); @@ -270,6 +273,13 @@ const creationReferenceValidator = plainObject(vObj({ 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 @@ -286,6 +296,7 @@ const metadataValidator = plainObject(vObj({ [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({ diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 4c3a38139540dd..332c2099f5ee92 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 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'; @@ -95,6 +96,7 @@ const sessionMetadata = { 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); @@ -194,6 +196,10 @@ export class AgentHostCatalogSourceResolver { 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), @@ -205,6 +211,7 @@ export class AgentHostCatalogSourceResolver { ...(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, @@ -263,6 +270,9 @@ export class AgentHostCatalogSourceResolver { 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; @@ -382,6 +392,14 @@ function readPersistedGitHubState(value: string | undefined): ISessionGitHubStat } } +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; diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index 1bad7e3d900d20..23eb0fcb8a2d51 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -94,6 +94,7 @@ export interface IAgentHostDatabaseSessionChat { export interface IAgentHostDatabaseSessionChatCatalog { readonly revision: number; readonly legacyMirroredRevision: number; + readonly legacyMirroredPayload?: string; readonly chats: readonly IAgentHostDatabaseSessionChat[]; } @@ -201,7 +202,9 @@ export interface IAgentHostDatabase extends IDisposable { /** Replaces authoritative peer-chat membership when 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): Promise; + 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; } @@ -368,6 +371,7 @@ function sessionsV2BackfillKey(provider: AgentProvider, payloadVersion: number): const sessionsV2ExcludedKeyPrefix = 'sessionsV2Excluded:'; const sessionsV2PayloadDirtyKeyPrefix = 'sessionsV2PayloadDirty:'; +const sessionChatCatalogLegacyMirrorKeyPrefix = 'sessionChatCatalogLegacyMirror:'; function sessionsV2ExcludedProviderPrefix(provider: AgentProvider): string { return `${sessionsV2ExcludedKeyPrefix}${provider}:`; @@ -381,6 +385,10 @@ 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}`; @@ -466,6 +474,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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]); @@ -655,6 +664,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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'); @@ -789,6 +799,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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}`); @@ -904,6 +915,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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}`); @@ -1086,6 +1098,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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, @@ -1094,7 +1107,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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`, [session]); + ORDER BY chat.chat_order`, [sessionChatCatalogLegacyMirrorKey(session), session]); const catalog = rows[0]; if (!catalog) { return undefined; @@ -1102,6 +1115,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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, @@ -1155,22 +1169,56 @@ export class AgentHostDatabase implements IAgentHostDatabase { }); } - async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number): Promise { + 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 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]); - return row?.revision === expectedRevision && row.legacy_mirrored_revision === expectedRevision; + 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}`); + } }); } diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index 69cb8d27857892..fba0b03a90d1da 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -57,14 +57,14 @@ export class AgentHostPeerChatStore { } const central = this._entriesFromCatalog(catalog.chats); if (catalog.legacyMirroredRevision !== catalog.revision) { - try { - await this._publishCompatibilityState(session, central, catalog.revision); - } catch (error) { - this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); - } - result = central; + result = (await this._reconcileUnmirroredCatalog(session))?.entries; return; } + if (legacy !== undefined && catalog.legacyMirroredPayload === undefined) { + if (!await this._database.markSessionChatCatalogLegacyMirrored(session.toString(), catalog.revision, JSON.stringify(legacy))) { + continue; + } + } if (legacy !== undefined && JSON.stringify(legacy) !== JSON.stringify(central)) { if (!await this._replaceCentral(session, legacy, catalog.revision)) { continue; @@ -177,12 +177,24 @@ export class AgentHostPeerChatStore { private async _applyWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { while (true) { - const catalog = await this._database.getSessionChatCatalog(session.toString()); + let catalog = await this._database.getSessionChatCatalog(session.toString()); + let reconciledEntries: IPersistedPeerChat[] | undefined; + if (catalog && catalog.legacyMirroredRevision !== catalog.revision) { + const reconciled = await this._reconcileUnmirroredCatalog(session); + catalog = await this._database.getSessionChatCatalog(session.toString()); + if (!reconciled || !catalog || catalog.revision !== reconciled.revision) { + continue; + } + reconciledEntries = reconciled.entries; + } const central = catalog ? this._entriesFromCatalog(catalog.chats) : undefined; - const legacy = !catalog || catalog.legacyMirroredRevision === catalog.revision - ? await this.tryReadLegacy(session) - : undefined; - const current = legacy ?? central ?? []; + const legacy = reconciledEntries ? undefined : await this.tryReadLegacy(session); + 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 current = reconciledEntries ?? legacy ?? central ?? []; const updated = this._parse(session, JSON.stringify(mutate(current))); if (await this._replaceCentral(session, updated, catalog?.revision)) { return; @@ -237,23 +249,85 @@ export class AgentHostPeerChatStore { private _enqueueLegacyMirror(session: URI): Promise { return this._enqueue(session, async () => { + await this._reconcileUnmirroredCatalog(session); + }); + } + + private async _reconcileUnmirroredCatalog(session: URI): Promise<{ readonly entries: IPersistedPeerChat[]; readonly revision: number } | undefined> { + while (true) { const catalog = await this._database.getSessionChatCatalog(session.toString()); - if (!catalog || catalog.legacyMirroredRevision === catalog.revision) { - return; + if (!catalog) { + return undefined; } - const entries = this._entriesFromCatalog(catalog.chats); - await this._publishCompatibilityState(session, entries, catalog.revision); - }); + const central = this._entriesFromCatalog(catalog.chats); + if (catalog.legacyMirroredRevision === catalog.revision) { + return { entries: central, revision: catalog.revision }; + } + const legacy = await this.tryReadLegacy(session); + const base = this._parseLegacyMirrorBase(session, catalog.legacyMirroredPayload); + if (legacy !== undefined && base !== undefined && JSON.stringify(legacy) !== JSON.stringify(base)) { + const merged = this._mergeLegacyChanges(base, central, legacy); + if (!await this._replaceCentral(session, merged, catalog.revision)) { + continue; + } + const revision = catalog.revision + 1; + if (!await this._database.recordSessionChatCatalogLegacyMirrorPayload(session.toString(), revision, JSON.stringify(legacy))) { + continue; + } + return { entries: merged, revision }; + } + try { + await this._publishCompatibilityState(session, central, catalog.revision); + } catch (error) { + this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); + } + return { entries: central, revision: catalog.revision }; + } } private async _writeLegacyMirror(session: URI, entries: readonly IPersistedPeerChat[], revision: number): Promise { + const payload = JSON.stringify(entries); const ref = this._sessionDataService.openDatabase(session); try { - await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(entries)); + await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, payload); } finally { ref.dispose(); } - return this._database.markSessionChatCatalogLegacyMirrored(session.toString(), revision); + return this._database.markSessionChatCatalogLegacyMirrored(session.toString(), revision, payload); + } + + 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 { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 3d99e7269b94cc..09ffab2206e080 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -23,7 +23,7 @@ import { AgentChatMigrationDeferred, AgentProvider, AgentSession, AgentSignal, I 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'; -import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { omitTransientSessionConfigValues, SessionConfigKey } from '../common/sessionConfigKeys.js'; import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; import { buildAnnotationsUri, parseAnnotationsUri } from '../common/annotationsUri.js'; import { AGENT_HOST_AUTOMATION_MIGRATION_CONFIG_KEY, isAgentHostAutomationMigrationCompletion } from '../common/automationMigration.js'; @@ -72,7 +72,7 @@ import { AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './age import { AGENT_HOST_CATALOG_PAYLOAD_VERSION } from './agentHostCatalogProjection.js'; import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult } from './agentHostCatalogReconciliationService.js'; import { IAgentHostStorageService } from './agentHostStorageService.js'; -import { AgentHostCatalogListReader } from './agentHostCatalogListReader.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'; @@ -2210,7 +2210,19 @@ export class AgentService extends Disposable implements IAgentService { return false; } }))); - await this._sessionRegistry.updateModifiedTimes(modifiedTimeAdvances); + if (modifiedTimeAdvances.length > 0) { + 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) { @@ -2599,41 +2611,49 @@ export class AgentService extends Disposable implements IAgentService { const registered = hiddenExternal.size > 0 ? allRegistered.filter(entry => !hiddenExternal.has(entry.session.toString())) : allRegistered; - const prewarmDisposables: IDisposable[] = []; - 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 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()) || this._unpersistedChatBackings.has(session.toString())) { + return undefined; + } + return { + registeredSession, + central: await this._catalogListReader.read(registeredSession), + }; + }))); + 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 { 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 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 } = 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())) { - return undefined; - } - if (this._unpersistedChatBackings.has(session.toString())) { + results = await Promise.all(catalogResults.map(result => fallbackLimiter.queue(async (): Promise => { + if (!result) { return undefined; } - - const central = await this._catalogListReader.read(registeredSession); + const { registeredSession, central } = result; + const { session } = registeredSession; if (central.eligible) { return central.metadata; } @@ -4383,7 +4403,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(); @@ -6009,7 +6029,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; @@ -6119,7 +6139,12 @@ export class AgentService extends Disposable implements IAgentService { if (m.configValues) { try { - persistedConfigValues = 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)}`); } diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts index d4654a5de76be7..9045cad8ae016a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts @@ -5,6 +5,7 @@ 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 { @@ -173,6 +174,40 @@ suite('AgentHostCatalogProjection', () => { }); }); + 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 = [ diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index 2f3b282f68b9a3..fc04ecb0ed8870 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -8,6 +8,7 @@ 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'; @@ -137,6 +138,33 @@ suite('AgentHostCatalogSourceResolver', () => { }); }); + 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 = { diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 043f78c09ee2fb..33d781287179e7 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -265,12 +265,17 @@ suite('AgentHostDatabase sessions_v2', () => { throw new Error('Expected the initial chat catalog write to succeed'); } const first = await database.getSessionChatCatalog(session); - const firstAcknowledged = await database.markSessionChatCatalogLegacyMirrored(session, firstRevision); + const firstAcknowledged = await database.markSessionChatCatalogLegacyMirrored(session, firstRevision, '[{"uri":"ahp-chat://first"}]'); const secondRevision = await database.replaceSessionChatCatalog(session, [ { chat: 'ahp-chat://second', order: 0, inheritedTurnId: 'turn-1' }, ], firstRevision); + if (secondRevision === undefined) { + throw new Error('Expected the second chat catalog write to succeed'); + } const conflictingRevision = await database.replaceSessionChatCatalog(session, [], firstRevision); - const staleAcknowledgement = await database.markSessionChatCatalogLegacyMirrored(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({ @@ -281,6 +286,8 @@ suite('AgentHostDatabase sessions_v2', () => { secondRevision, conflictingRevision, staleAcknowledgement, + afterStaleAcknowledgement, + baseRecorded, second, }, { before: undefined, @@ -297,9 +304,19 @@ suite('AgentHostDatabase sessions_v2', () => { secondRevision: 2, conflictingRevision: undefined, 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' }, ], diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index bfb868835f212c..24fe108fd836ac 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -24,6 +24,22 @@ const origin = { 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); + } +} + suite('AgentHostPeerChatStore', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -189,4 +205,71 @@ suite('AgentHostPeerChatStore', () => { 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/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 28ea2e337b6df4..dc95ac96563081 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -316,6 +316,8 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { private readonly _sessionChats = new Map(); registryWriteAttempts = 0; private _remainingRegistryWriteFailures = 0; + modifiedTimeBatchAttempts = 0; + private _remainingModifiedTimeBatchFailures = 0; readonly externalUpdates: { session: string; external: boolean }[] = []; undefinedExternalListCalls = 0; sessionV2UpsertAttempts = 0; @@ -342,6 +344,11 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._remainingRegistryWriteFailures = count; } + failModifiedTimeBatches(count: number): void { + this.modifiedTimeBatchAttempts = 0; + this._remainingModifiedTimeBatchFailures = count; + } + async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { this._beforeWrite(); if (registerOptions.checkTombstone && this._tombstones.has(session)) { @@ -408,6 +415,11 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { } 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) { await this.updateSessionModifiedTime(session, modifiedTime); } @@ -674,12 +686,20 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._sessionChats.set(session, { revision, legacyMirroredRevision: current?.legacyMirroredRevision ?? 0, chats: [...chats] }); return revision; } - async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number): Promise { + 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, legacyMirroredRevision: expectedRevision }); + this._sessionChats.set(session, { ...current, legacyMirroredPayload: payload }); return true; } @@ -989,12 +1009,20 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { this._sessionChats.set(session, { revision, legacyMirroredRevision: current?.legacyMirroredRevision ?? 0, chats: [...chats] }); return revision; } - async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number): Promise { + 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 }); + 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; } @@ -3691,11 +3719,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; } @@ -3705,6 +3733,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 } : {}), })); } @@ -3712,14 +3741,14 @@ 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._meta ? { _meta: entry._meta } : {}) } : undefined; + return entry ? { session, startTime: entry.modifiedTime, modifiedTime: entry.modifiedTime, ...(entry.summary ? { summary: entry.summary } : {}), ...(entry._meta ? { _meta: entry._meta } : {}) } : undefined; } } @@ -3741,11 +3770,17 @@ suite('AgentService (node dispatcher)', () => { 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 { @@ -4074,14 +4109,74 @@ suite('AgentService (node dispatcher)', () => { 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('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 timeout(0); + + 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; + }; + + 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('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'); @@ -4169,11 +4264,13 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ sessions: listed.map(metadata => metadata.session.toString()), providerMetadataCalls: agent.metadataCalls, + providerPrewarmCalls: agent.prewarmCalls, sessionDatabaseOpenSessions: [...new Set(databaseOpens)], sessionDatabaseOpenCount: databaseOpens.length, }, { sessions: [centralSession.toString(), fallbackSession.toString()], providerMetadataCalls: [fallbackSession.toString()], + providerPrewarmCalls: 1, sessionDatabaseOpenSessions: [buildDefaultChatUri(fallbackSession), fallbackSession.toString()], sessionDatabaseOpenCount: 4, }); @@ -4381,23 +4478,63 @@ suite('AgentService (node dispatcher)', () => { 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); + 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' }); - await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [discoveredChat(session, true, rediscoveredModifiedTime)]); + 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, }); }); @@ -16916,16 +17053,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: [] }, @@ -16936,10 +17079,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' }, diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index fae93e7897c3e6..07637e1f02ed7e 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts @@ -259,6 +259,7 @@ class TestAgentHostDatabase implements IAgentHostDatabase { async getSessionChatCatalog(_session: string): Promise { return undefined; } async replaceSessionChatCatalog(_session: string, _chats: readonly IAgentHostDatabaseSessionChat[], _expectedRevision: number | undefined): Promise { return 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 { } From f9075244843f4f2c957ef5b423813a3b6702e0ba Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 3 Sep 2026 07:15:18 +0200 Subject: [PATCH 16/30] agentHost: persist unloaded session flags centrally Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostDatabase.ts | 11 +++- .../platform/agentHost/node/agentService.ts | 56 ++++++++++++++++++- .../test/node/agentHostDatabase.test.ts | 39 +++++++++++++ .../agentHost/test/node/agentService.test.ts | 13 ++++- .../e2e/suites/sessionPersistenceSuite.ts | 12 ++++ 5 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index 23eb0fcb8a2d51..32157a3d5bf5de 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -286,15 +286,20 @@ const migrations = [ }, ] 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 { - const hasFinalCatalog = (currentVersion === 5 || currentVersion === 11) - && 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'`, []); if (!hasFinalCatalog) { const sessionColumns = await all(database, 'PRAGMA table_info(sessions)', []); if (!sessionColumns.some(column => column.name === 'modified_time')) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index d88f2b695a62b0..3c6c1e099c5e65 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -69,7 +69,7 @@ import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, customChat import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts, type ISessionArtifact } from '../common/sessionArtifacts.js'; import { AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; -import { AGENT_HOST_CATALOG_PAYLOAD_VERSION } from './agentHostCatalogProjection.js'; +import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult } from './agentHostCatalogReconciliationService.js'; import { IAgentHostStorageService } from './agentHostStorageService.js'; import { AgentHostCatalogListReader, AgentHostCatalogListResult } from './agentHostCatalogListReader.js'; @@ -5053,14 +5053,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 () => { + 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); + 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 diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 33d781287179e7..7bdfde3d6da2c4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -497,6 +497,45 @@ suite('AgentHostDatabase sessions_v2', () => { }); }); + 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'; diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 20c8694f3427fc..98dc98f694bcbd 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -10876,8 +10876,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'); @@ -11042,20 +11043,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, }); }); 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 2ab33a1f90505a..26945153b4f273 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -153,6 +153,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 }); @@ -161,9 +171,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, }); }); From 32714bcdf5ac3e317bafac1ea1c393ec9994440c Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 3 Sep 2026 07:38:48 +0200 Subject: [PATCH 17/30] agentHost: harden peer chat catalog lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostCatalogProjection.ts | 14 +- .../node/agentHostCatalogSourceResolver.ts | 38 +++- .../agentHost/node/agentHostDatabase.ts | 27 ++- .../agentHost/node/agentHostPeerChatStore.ts | 114 +++++++++--- .../platform/agentHost/node/agentService.ts | 113 +++++------ .../test/node/agentHostDatabase.test.ts | 43 ++++- .../test/node/agentHostPeerChatStore.test.ts | 68 ++++++- .../agentHost/test/node/agentService.test.ts | 175 +++++++++++++++++- .../test/node/agentSessionRegistry.test.ts | 4 +- 9 files changed, 482 insertions(+), 114 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index eb37061370a1df..3100765acd2e78 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -18,8 +18,8 @@ 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_STRING_LENGTH = 4096; const MAX_JSON_DEPTH = 20; const MAX_JSON_ENTRIES = 2000; @@ -108,9 +108,9 @@ class JsonValueValidator extends ValidatorBase { return { value }; } if (typeof value === 'string') { - return value.length <= MAX_STRING_LENGTH + return value.length <= AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT ? { value } - : { error: { message: `String exceeds ${MAX_STRING_LENGTH} characters.` } }; + : { error: { message: `String exceeds ${AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT} characters.` } }; } if (typeof value === 'number') { return Number.isFinite(value) @@ -147,8 +147,8 @@ class JsonValueValidator extends ValidatorBase { } const result: { [key: string]: JsonValue } = {}; for (const key of keys) { - if (key.length > MAX_STRING_LENGTH) { - return { error: { message: `JSON key exceeds ${MAX_STRING_LENGTH} characters.` } }; + 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) { @@ -170,8 +170,8 @@ class JsonValueValidator extends ValidatorBase { } } -const boundedString = (maximumLength = MAX_STRING_LENGTH) => new StringValidator(maximumLength, false); -const uriString = () => new StringValidator(MAX_STRING_LENGTH, true); +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(); diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 332c2099f5ee92..9adf77fa125e49 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -10,7 +10,7 @@ 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_TITLE_LENGTH_LIMIT, AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, agentHostCatalogGitValidator } from './agentHostCatalogProjection.js'; +import { AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT, AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, 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'; @@ -29,7 +29,7 @@ export interface ICatalogSourceState { readonly uri: string; readonly kind: 'default' | 'peer'; readonly title?: string; - readonly origin?: AgentHostCatalogJsonValue; + readonly origin?: ChatOrigin; }[]; } @@ -246,7 +246,7 @@ export class AgentHostCatalogSourceResolver { kind: chat.kind, summary: toCatalogSummary(summary), titleSource: normalizeCatalogTitleSource(titleSource), - origin: chat.origin, + origin: toCatalogChatOrigin(chat.origin), }; }), }; @@ -309,7 +309,7 @@ function toCatalogSummary(value: string | undefined): string | undefined { return `${value.slice(0, end)}…`; } -export function toCatalogJsonValue(value: unknown): AgentHostCatalogJsonValue | undefined { +export function toSerializableJsonValue(value: unknown): AgentHostCatalogJsonValue | undefined { if (value === undefined) { return undefined; } @@ -322,7 +322,7 @@ export function toCatalogJsonValue(value: unknown): AgentHostCatalogJsonValue | if (Array.isArray(value)) { const result: AgentHostCatalogJsonValue[] = []; for (const entry of value) { - const converted = toCatalogJsonValue(entry); + const converted = toSerializableJsonValue(entry); if (converted !== undefined) { result.push(converted); } @@ -332,7 +332,7 @@ export function toCatalogJsonValue(value: unknown): AgentHostCatalogJsonValue | if (typeof value === 'object') { const result: { [key: string]: AgentHostCatalogJsonValue } = {}; for (const [key, entry] of Object.entries(value)) { - const converted = toCatalogJsonValue(entry); + const converted = toSerializableJsonValue(entry); if (converted !== undefined) { result[key] = converted; } @@ -342,6 +342,32 @@ export function toCatalogJsonValue(value: unknown): AgentHostCatalogJsonValue | 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; diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index 32157a3d5bf5de..c8cab071a9c10e 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -98,6 +98,10 @@ export interface IAgentHostDatabaseSessionChatCatalog { 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 { @@ -199,8 +203,8 @@ export interface IAgentHostDatabase extends IDisposable { 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 its revision still matches. */ - replaceSessionChatCatalog(session: string, chats: readonly IAgentHostDatabaseSessionChat[], expectedRevision: number | undefined): 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. */ @@ -1131,7 +1135,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { }; } - async replaceSessionChatCatalog(session: string, chats: readonly IAgentHostDatabaseSessionChat[], expectedRevision: number | undefined): Promise { + 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'); @@ -1140,11 +1144,24 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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 undefined; + return { status: 'conflict' }; } const revision = (currentRevision ?? 0) + 1; if (!Number.isSafeInteger(revision)) { @@ -1167,7 +1184,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { ]); } await exec(database, 'COMMIT'); - return revision; + return { status: 'applied', revision }; } catch (error) { return this._rollback(database, error, `Failed to replace the chat catalog for ${session}`); } diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index fba0b03a90d1da..414d15b7e44d4d 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ 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 { ChatOrigin } from '../common/state/protocol/state.js'; import { isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; -import { fromCatalogChatOrigin, toCatalogJsonValue } from './agentHostCatalogSourceResolver.js'; +import { fromCatalogChatOrigin, toSerializableJsonValue } from './agentHostCatalogSourceResolver.js'; import { IAgentHostDatabase } from './agentHostDatabase.js'; export const PEER_CHATS_METADATA_KEY = 'peerChats'; @@ -27,6 +28,7 @@ export interface IPersistedPeerChat { export class AgentHostPeerChatStore { private readonly _writes = new Map>(); + private readonly _deletingSessions = new Map(); constructor( private readonly _database: IAgentHostDatabase, @@ -49,15 +51,20 @@ export class AgentHostPeerChatStore { if (legacy === undefined) { return; } - if (!await this._replaceCentral(session, legacy, undefined)) { + const replaceResult = await this._replaceCentral(session, legacy, undefined); + if (replaceResult === 'conflict') { continue; } + if (replaceResult === 'sessionUnavailable') { + return; + } result = legacy; return; } const central = this._entriesFromCatalog(catalog.chats); if (catalog.legacyMirroredRevision !== catalog.revision) { - result = (await this._reconcileUnmirroredCatalog(session))?.entries; + const reconciled = await this._reconcileUnmirroredCatalog(session); + result = reconciled.status === 'available' ? reconciled.entries : undefined; return; } if (legacy !== undefined && catalog.legacyMirroredPayload === undefined) { @@ -66,15 +73,25 @@ export class AgentHostPeerChatStore { } } if (legacy !== undefined && JSON.stringify(legacy) !== JSON.stringify(central)) { - if (!await this._replaceCentral(session, legacy, catalog.revision)) { + const replaceResult = await this._replaceCentral(session, legacy, catalog.revision); + if (replaceResult === 'conflict') { continue; } + if (replaceResult === 'sessionUnavailable') { + return; + } result = legacy; return; } - const local = await Promise.all(central.map(entry => this._readChatMetadata(entry))); - if (JSON.stringify(local) !== JSON.stringify(central) && !await this._replaceCentral(session, local, catalog.revision)) { - continue; + const local = await this.readLocalChatMetadata(central); + if (JSON.stringify(local) !== JSON.stringify(central)) { + const replaceResult = await this._replaceCentral(session, local, catalog.revision); + if (replaceResult === 'conflict') { + continue; + } + if (replaceResult === 'sessionUnavailable') { + return; + } } result = local; return; @@ -152,12 +169,43 @@ export class AgentHostPeerChatStore { 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(4); + 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 */ }) @@ -181,8 +229,14 @@ export class AgentHostPeerChatStore { 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 (!reconciled || !catalog || catalog.revision !== reconciled.revision) { + if (!catalog || catalog.revision !== reconciled.revision) { continue; } reconciledEntries = reconciled.entries; @@ -196,29 +250,33 @@ export class AgentHostPeerChatStore { } const current = reconciledEntries ?? legacy ?? central ?? []; const updated = this._parse(session, JSON.stringify(mutate(current))); - if (await this._replaceCentral(session, updated, catalog?.revision)) { + const result = await this._replaceCentral(session, updated, catalog?.revision); + if (result !== 'conflict') { return; } } } - private async _replaceCentral(session: URI, updated: readonly IPersistedPeerChat[], expectedRevision: number | undefined): Promise { - const revision = await this._database.replaceSessionChatCatalog(session.toString(), updated.map((entry, order) => ({ + private async _replaceCentral(session: URI, updated: readonly IPersistedPeerChat[], expectedRevision: number | undefined): Promise<'applied' | 'conflict' | 'sessionUnavailable'> { + const result = await this._database.replaceSessionChatCatalog(session.toString(), updated.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 } : {}), })), expectedRevision); - if (revision === undefined) { - return false; + 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'; } try { - await this._publishCompatibilityState(session, updated, revision); + await this._publishCompatibilityState(session, updated, result.revision); } catch (error) { this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); } - return true; + return 'applied'; } private async _publishCompatibilityState(session: URI, initialEntries: readonly IPersistedPeerChat[], initialRevision: number): Promise { @@ -253,35 +311,43 @@ export class AgentHostPeerChatStore { }); } - private async _reconcileUnmirroredCatalog(session: URI): Promise<{ readonly entries: IPersistedPeerChat[]; readonly revision: number } | undefined> { + private async _reconcileUnmirroredCatalog(session: URI): 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 undefined; + return { status: 'missingCatalog' }; } const central = this._entriesFromCatalog(catalog.chats); if (catalog.legacyMirroredRevision === catalog.revision) { - return { entries: central, revision: catalog.revision }; + return { status: 'available', entries: central, revision: catalog.revision }; } const legacy = await this.tryReadLegacy(session); const base = this._parseLegacyMirrorBase(session, catalog.legacyMirroredPayload); if (legacy !== undefined && base !== undefined && JSON.stringify(legacy) !== JSON.stringify(base)) { const merged = this._mergeLegacyChanges(base, central, legacy); - if (!await this._replaceCentral(session, merged, catalog.revision)) { + const replaceResult = await this._replaceCentral(session, merged, catalog.revision); + 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; } - return { entries: merged, revision }; + return { status: 'available', entries: merged, revision }; } try { await this._publishCompatibilityState(session, central, catalog.revision); } catch (error) { this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); } - return { entries: central, revision: catalog.revision }; + return { status: 'available', entries: central, revision: catalog.revision }; } } @@ -374,11 +440,11 @@ export class AgentHostPeerChatStore { private _parseOrigin(raw: string): ChatOrigin | undefined { const parsed: unknown = JSON.parse(raw); - return fromCatalogChatOrigin(toCatalogJsonValue(parsed)); + return fromCatalogChatOrigin(toSerializableJsonValue(parsed)); } private _stringifyOrigin(origin: ChatOrigin): string { - const value = toCatalogJsonValue(origin); + const value = toSerializableJsonValue(origin); if (value === undefined) { throw new Error('Chat origin is not JSON-serializable'); } @@ -436,7 +502,7 @@ export class AgentHostPeerChatStore { this._logService.warn(`[AgentService] Skipping peer-chat catalog entry ${index} with invalid inherited turn id`); continue; } - const originValue = toCatalogJsonValue(value.origin); + 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}`); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 3c6c1e099c5e65..2119eeff26a30e 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -101,7 +101,7 @@ 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, toCatalogJsonValue } from './agentHostCatalogSourceResolver.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'; @@ -1761,10 +1761,7 @@ export class AgentService extends Disposable implements IAgentService { workingDirectories: summary.workingDirectories ?? [], changes: summary.changes, meta: summary._meta, - chats: (chatsOverride ?? this._catalogChatsFromState(state)).map(chat => ({ - ...chat, - origin: toCatalogJsonValue(chat.origin), - })), + chats: chatsOverride ?? this._catalogChatsFromState(state), }, metadataOverrides, false)); if (result.status === 'pending') { this._logService.warn(`[AgentService] Catalog synchronization for ${sessionKey} remains pending: ${result.reason}`); @@ -1801,7 +1798,7 @@ export class AgentService extends Disposable implements IAgentService { ...peers.map(peer => ({ uri: peer.uri, kind: 'peer' as const, - origin: toCatalogJsonValue(peer.origin), + origin: peer.origin, })), ], }, {}, true), @@ -2400,7 +2397,7 @@ export class AgentService extends Disposable implements IAgentService { ...peers.map(peer => ({ uri: peer.uri, kind: 'peer' as const, - origin: toCatalogJsonValue(peer.origin), + origin: peer.origin, })), ], }, external && seedExternalRead ? { [AH_META_IS_READ_DB_KEY]: 'true' } : {}, true); @@ -4599,53 +4596,58 @@ export class AgentService extends Disposable implements IAgentService { const sessionId = AgentSession.id(session); const persistedPeerChats = sessionChats.length === 0 ? await this._peerChatStore.tryRead(session) : undefined; const worktree = await this._worktree.prepareSessionDeletion(session, sessionId); - 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)]; - } - 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()); - if (isEphemeral) { - await this._retryRegistryMutation( - () => this._sessionRegistry.clearTombstone(session), - `clearing ephemeral session tombstone for ${session.toString()}`, - ); + await this._peerChatStore.beginSessionDeletion(session); + try { + 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)]; + } + 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()); + if (isEphemeral) { + await this._retryRegistryMutation( + () => this._sessionRegistry.clearTombstone(session), + `clearing ephemeral session tombstone for ${session.toString()}`, + ); + } + } finally { + this._peerChatStore.endSessionDeletion(session); } } @@ -6424,11 +6426,12 @@ export class AgentService extends Disposable implements IAgentService { } const cached = await this._readCentralChatCatalog(session); if (cached?.some(chat => chat.kind === 'peer')) { - const peers = cached.filter(chat => chat.kind === 'peer').map(chat => ({ + const projectedPeers = cached.filter(chat => chat.kind === 'peer').map(chat => ({ uri: chat.uri, ...(chat.origin !== undefined ? { origin: chat.origin } : {}), ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), })); + const peers = await this._peerChatStore.readLocalChatMetadata(projectedPeers); await this._peerChatStore.replace(session, peers); return peers; } diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index 7bdfde3d6da2c4..d3f95cc1c9f596 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -257,21 +257,23 @@ suite('AgentHostDatabase sessions_v2', () => { }, { checkTombstone: false }); const before = await database.getSessionChatCatalog(session); - const firstRevision = await database.replaceSessionChatCatalog(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 (firstRevision === 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 secondRevision = await database.replaceSessionChatCatalog(session, [ + const secondResult = await database.replaceSessionChatCatalog(session, [ { chat: 'ahp-chat://second', order: 0, inheritedTurnId: 'turn-1' }, ], firstRevision); - if (secondRevision === undefined) { + 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); @@ -302,7 +304,7 @@ suite('AgentHostDatabase sessions_v2', () => { }, firstAcknowledged: true, secondRevision: 2, - conflictingRevision: undefined, + conflictingRevision: { status: 'conflict' }, staleAcknowledgement: false, afterStaleAcknowledgement: { revision: 2, @@ -324,6 +326,37 @@ suite('AgentHostDatabase sessions_v2', () => { }); }); + 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) { diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index 24fe108fd836ac..62a92e77c8e6dd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -10,7 +10,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { ChatOriginKind } from '../../common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js'; import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; -import { AgentHostPeerChatStore, PEER_CHATS_METADATA_KEY } from '../../node/agentHostPeerChatStore.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'); @@ -135,6 +135,72 @@ suite('AgentHostPeerChatStore', () => { ]); }); + 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); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 98dc98f694bcbd..fdc8608c0e6313 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -47,9 +47,9 @@ import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../co import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; -import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; -import { CHAT_PROVIDER_DATA_METADATA_KEY } from '../../node/agentHostPeerChatStore.js'; -import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, type AgentHostCatalogData } from '../../node/agentHostCatalogProjection.js'; +import { AgentHostDatabase, AgentHostDatabaseSessionChatCatalogReplaceResult, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.js'; +import { CHAT_ORIGIN_METADATA_KEY, CHAT_PROVIDER_DATA_METADATA_KEY } 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_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; @@ -318,6 +318,8 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { private _remainingRegistryWriteFailures = 0; 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; @@ -349,6 +351,15 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { 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)) { @@ -677,14 +688,27 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { async getSessionChatCatalog(session: string): Promise { return this._sessionChats.get(session); } - async replaceSessionChatCatalog(session: string, chats: readonly IAgentHostDatabaseSessionChat[], expectedRevision: number | undefined): Promise { + 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 undefined; + return { status: 'conflict' }; } const revision = (current?.revision ?? 0) + 1; this._sessionChats.set(session, { revision, legacyMirroredRevision: current?.legacyMirroredRevision ?? 0, chats: [...chats] }); - return revision; + return { status: 'applied', revision }; } async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number, payload?: string): Promise { const current = this._sessionChats.get(session); @@ -1000,14 +1024,20 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { async getSessionChatCatalog(session: string): Promise { return this._sessionChats.get(session); } - async replaceSessionChatCatalog(session: string, chats: readonly IAgentHostDatabaseSessionChat[], expectedRevision: number | undefined): Promise { + 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 undefined; + return { status: 'conflict' }; } const revision = (current?.revision ?? 0) + 1; this._sessionChats.set(session, { revision, legacyMirroredRevision: current?.legacyMirroredRevision ?? 0, chats: [...chats] }); - return revision; + return { status: 'applied', revision }; } async markSessionChatCatalogLegacyMirrored(session: string, expectedRevision: number, payload?: string): Promise { const current = this._sessionChats.get(session); @@ -3596,6 +3626,75 @@ 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('is a no-op for unknown sessions', async () => { registerTestAgentProvider(service, copilotAgent); const unknownSession = URI.from({ scheme: 'unknown', path: '/nope' }); @@ -12592,6 +12691,64 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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, + }, { + liveSelectionMatches: true, + legacySelectionMatches: true, + peerCatalogSelectionMatches: true, + chatMetadataSelectionMatches: true, + centralOrigin: { kind: ChatOriginKind.SideChat, chat: defaultChatUri, turnId: 't1' }, + }); + }); + test('creates a side chat from a completed local turn without losing its stable source turn identity', async () => { const db = new TestSessionDatabase(); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index 07637e1f02ed7e..a786e3f145272c 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, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseExternalUpdate, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } 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 { @@ -257,7 +257,7 @@ class TestAgentHostDatabase implements IAgentHostDatabase { 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 1; } + 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; } From 76757e11f0ee548460935ac49f74016cb095c866 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 3 Sep 2026 08:24:40 +0200 Subject: [PATCH 18/30] agentHost: preserve peer backing during recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentService.ts | 44 +++-- .../node/agentHostCatalogProjection.test.ts | 47 +++--- .../agentHost/test/node/agentService.test.ts | 159 ++++++++++++++++++ 3 files changed, 213 insertions(+), 37 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 2119eeff26a30e..79339df7ed1ec7 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -3722,19 +3722,26 @@ export class AgentService extends Disposable implements IAgentService { ...(createResult?.inheritedTurnId !== undefined ? { inheritedTurnId: createResult.inheritedTurnId } : {}), }); } catch (error) { - let catalogRollbackError: Error | undefined; - try { - await this._peerChatStore.remove(session, chat); - } catch (rollbackError) { - catalogRollbackError = rollbackError instanceof Error ? rollbackError : new Error(String(rollbackError)); - } - try { - await provider.chats.disposeChat(chat, this._chatContext(session, chat)); - } catch (rollbackError) { - throw new AggregateError([error, ...(catalogRollbackError ? [catalogRollbackError] : []), rollbackError], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(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 (catalogRollbackError) { - throw new AggregateError([error, catalogRollbackError], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(error)}`); + if (rollbackErrors.length > 0) { + throw new AggregateError([error, ...rollbackErrors], `Failed to persist and roll back chat ${chat.toString()}: ${toErrorMessage(error)}`); } throw error; } finally { @@ -6426,12 +6433,21 @@ export class AgentService extends Disposable implements IAgentService { } const cached = await this._readCentralChatCatalog(session); if (cached?.some(chat => chat.kind === 'peer')) { - const projectedPeers = cached.filter(chat => chat.kind === 'peer').map(chat => ({ + 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 } : {}), })); - const peers = await this._peerChatStore.readLocalChatMetadata(projectedPeers); + const legacy = await agent.listLegacyChatBackings?.(session).catch(error => { + this._logService.warn(`[AgentService] Failed to enrich cached peer-chat membership for ${session.toString()}`, error); + return []; + }) ?? []; + 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; } diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts index 9045cad8ae016a..280d9d23f19871 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogProjection.test.ts @@ -147,30 +147,31 @@ suite('AgentHostCatalogProjection', () => { 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, - }, - }); + }); + + 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), - }); + 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), }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index fdc8608c0e6313..25c3ee125319f3 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -14159,6 +14159,81 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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; @@ -14228,6 +14303,90 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('failed peer creation removes chat-local title data before parent deletion', async () => { + class FailingCatalogSourceDatabase extends TestSessionDatabase { + failMetadataReads = false; + + 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); + } + } + class MultiChatAgent extends MockAgent { + readonly disposedPeers: string[] = []; + + override async createChat(): Promise { + return { providerData: 'failed-peer-backing' }; + } + + override async disposeChat(_session: URI, chat: URI): Promise { + this.disposedPeers.push(chat.toString()); + } + } + 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('keeps a new peer chat when its downgrade mirror cannot be persisted', async () => { class FailingPeerCatalogDatabase extends TestSessionDatabase { failPeerCatalogWrites = false; From e1bc2e30576acd4914f1e546252114a3d93feac2 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 3 Sep 2026 10:06:24 +0200 Subject: [PATCH 19/30] agentHost: harden catalog reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostBootstrap.ts | 2 + .../node/agentHostCatalogProjection.ts | 4 +- .../agentHostCatalogReconciliationService.ts | 238 +++++-- .../node/agentHostCatalogSourceResolver.ts | 34 +- .../agentHost/node/agentHostDatabase.ts | 99 ++- .../agentHost/node/agentHostPeerChatStore.ts | 7 + .../node/agentHostSessionTitleController.ts | 31 +- .../agentHostSessionsV2MigrationService.ts | 37 +- .../platform/agentHost/node/agentService.ts | 228 +++++-- .../sessionTitle/sessionTitleContribution.ts | 2 +- .../agentHost/node/copilot/copilotAgent.ts | 4 + .../node/localCommands/localChatCommand.ts | 4 +- .../node/localCommands/renameLocalCommand.ts | 2 +- ...ntHostCatalogReconciliationService.test.ts | 321 +++++++++- .../agentHostCatalogSourceResolver.test.ts | 69 ++ .../test/node/agentHostDatabase.test.ts | 127 ++++ .../test/node/agentHostPeerChatStore.test.ts | 59 +- .../agentHostSessionTitleController.test.ts | 30 +- .../agentHost/test/node/agentService.test.ts | 590 +++++++++++++++++- .../test/node/agentServiceTestUtils.ts | 5 + .../test/node/agentSessionRegistry.test.ts | 6 +- .../agentHost/test/node/copilotAgent.test.ts | 6 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 103 +-- 23 files changed, 1702 insertions(+), 306 deletions(-) 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/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index 3100765acd2e78..bbb777461e858a 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -188,7 +188,7 @@ function plainObject(validator: IValidator): ValidatorBase { : { message: 'Expected a plain object.' }); } -const changesValidator = plainObject(vObj({ +export const agentHostCatalogChangesValidator = plainObject(vObj({ additions: vOptionalProp(safeInteger()), deletions: vOptionalProp(safeInteger()), files: vOptionalProp(safeInteger()), @@ -336,7 +336,7 @@ export const agentHostCatalogDataValidator = plainObject(vObj({ project: vOptionalProp(projectValidator), isChatBacking: vOptionalProp(vBoolean()), workingDirectories: workingDirectoriesValidator, - changes: vOptionalProp(changesValidator), + changes: vOptionalProp(agentHostCatalogChangesValidator), _meta: vOptionalProp(metadataValidator), chats: chatsValidator, })); diff --git a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts index 2583f4da570226..036dc865edb53d 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts @@ -11,7 +11,7 @@ import { ILogService } from '../../log/common/log.js'; import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionDataService } from '../common/sessionDataService.js'; import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, hashAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; import { AgentHostCatalogSyncResult, AgentHostCatalogSyncService, catalogLegacyMetadataMatches, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.js'; -import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; +import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; import type { IRegisteredSession } from './agentSessionRegistry.js'; import type { IAgentHostStorageService } from './agentHostStorageService.js'; @@ -22,6 +22,11 @@ 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; +} export type AgentHostCatalogReconciliationOutcome = | { readonly session: string; readonly status: 'skipped'; readonly reason: 'synchronized' } @@ -62,10 +67,10 @@ export class AgentHostCatalogReconciliationService extends Disposable { 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 _scheduledBackgroundPass = false; private _running: Promise | undefined; private _rerunRequested = false; private _periodic = false; @@ -100,15 +105,10 @@ export class AgentHostCatalogReconciliationService extends Disposable { void this.runPass(); return; } - if (this._scheduledPass.value) { + if (this._scheduledPassKind === 'background') { return; } - this._scheduledBackgroundPass = true; - this._scheduledPass.value = this._schedule(() => { - this._scheduledPass.clear(); - this._scheduledBackgroundPass = false; - this.start(); - }, this._backgroundDelayMs); + this._schedulePass('background', this._backgroundDelayMs); } start(): void { @@ -117,16 +117,12 @@ export class AgentHostCatalogReconciliationService extends Disposable { } this._periodic = true; this._scheduledPass.clear(); - this._scheduledBackgroundPass = false; - const wasRunning = this._running !== undefined; + this._scheduledPassKind = undefined; const pass = this.runPass(); - if (wasRunning) { - return; - } void pass.then( report => this._logOutcomes(report.outcomes), error => this._logService.error('[AgentHostCatalogReconciliation] Background pass failed', error), - ).finally(() => this._scheduleNextPass()); + ); } runPass(): Promise { @@ -137,25 +133,32 @@ export class AgentHostCatalogReconciliationService extends Disposable { this._rerunRequested = true; return this._running; } - this._running = this._runPassLoop().finally(() => { - this._running = undefined; - }); - 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(); - return this.runPass(); + 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 { - await this._prepareFullVerification(); - if (this._scheduledBackgroundPass) { + if (this._scheduledPassKind === 'background') { this._scheduledPass.clear(); - this._scheduledBackgroundPass = false; - this.start(); - } else { + this._scheduledPassKind = undefined; await this.runPass(); + } else { + while (this._running) { + await this._running; + } } while (this._running) { await this._running; @@ -169,8 +172,20 @@ export class AgentHostCatalogReconciliationService extends Disposable { super.dispose(); } - private async _runPassLoop(): Promise { - let report = await this._runSinglePass(this._cancellation.token); + 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; @@ -186,6 +201,43 @@ export class AgentHostCatalogReconciliationService extends Disposable { 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(), @@ -193,24 +245,21 @@ export class AgentHostCatalogReconciliationService extends Disposable { const receiptBySession = new Map(initialReceipts.map(receipt => [receipt.session, receipt])); const sessions = [...listedSessions] .filter(session => receiptBySession.get(session.session.toString())?.payloadDirty !== 0) - .sort((a, b) => a.session.toString().localeCompare(b.session.toString())); - if (sessions.length === 0) { - this._storageService.delete(this._cursorStorageKey); - return { outcomes: [], cursor: undefined }; - } + .sort((first, second) => compareSessionKeys(first.session.toString(), second.session.toString())); + return { sessions, receiptBySession }; + } - const selected = this._selectBatch(sessions, this._readCursor()); + private _runBatch( + selected: readonly IRegisteredSession[], + receiptBySession: ReadonlyMap, + token: CancellationToken, + ): Promise { const limiter = new Limiter(this._concurrency); - const outcomes = await Promise.all(selected.map(registered => limiter.queue(() => this._reconcileSession( + return Promise.all(selected.map(registered => limiter.queue(() => this._reconcileSession( registered, receiptBySession.get(registered.session.toString()), token, )))); - const cursor = selected.at(-1)?.session.toString(); - if (cursor && !token.isCancellationRequested) { - this._storageService.set(this._cursorStorageKey, cursor); - } - return { outcomes, cursor }; } private async _reconcileSession(registered: IRegisteredSession, receipt: IAgentHostDatabaseSessionV2Receipt | undefined, token: CancellationToken): Promise { @@ -229,22 +278,12 @@ export class AgentHostCatalogReconciliationService extends Disposable { return { session: sessionKey, status: 'retry', reason: 'missingDatabase' }; } try { - const sourceResult = await this._resolveSource(registered); - 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 this._catalogSyncService.runExclusive(session, async synchronize => { + const replay = await this._catalogSyncService.runExclusive(session, async () => { const latestReceipt = await this._catalogDatabase.getSessionV2(sessionKey); if (receipt ? latestReceipt?.payloadDirty !== receipt.payloadDirty : latestReceipt !== undefined) { return { session: sessionKey, status: 'retry', reason: 'superseded' }; } const snapshot = await database.object.getCatalogSyncSnapshot(); - let replayedRevision: number | undefined; // 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 @@ -259,7 +298,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { } if (replayable) { const current = await database.object.getCatalogSyncSnapshot(); - const replay = current?.state !== 'pending' + const outcome = current?.state !== 'pending' ? { session: sessionKey, status: 'succeeded', @@ -267,17 +306,38 @@ export class AgentHostCatalogReconciliationService extends Disposable { sourceRevision: current?.sourceRevision ?? snapshot.sourceRevision, } satisfies Extract : await this._replayPending(session, current, acknowledgement => database.object.acknowledgeCatalogSyncSnapshot(acknowledgement), token); - if (replay.status !== 'succeeded') { - if (replay.status !== 'retry' || (replay.reason !== 'staleIncarnation' && replay.reason !== 'missingCatalog')) { - return replay; + if (outcome.status === 'succeeded') { + if (!await this._markPayloadClean(sessionKey, latestReceipt)) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; } - } else { - replayedRevision = replay.sourceRevision; + 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' }; + if (token.isCancellationRequested) { + return { session: sessionKey, status: 'retry', reason: 'cancelled' }; + } + const sourceResult = await this._resolveSource(registered); + 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 this._catalogSyncService.runExclusive(session, async synchronize => { + const latestReceipt = await this._catalogDatabase.getSessionV2(sessionKey); + if (receipt ? latestReceipt?.payloadDirty !== receipt.payloadDirty : latestReceipt !== undefined) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; } const currentSnapshot = await database.object.getCatalogSyncSnapshot(); if (token.isCancellationRequested) { @@ -288,13 +348,44 @@ export class AgentHostCatalogReconciliationService extends Disposable { if (legacyMetadataMatches && expected.ok && currentSnapshot?.payloadHash === expected.value.payloadHash - && matchesAcknowledgedCatalogReceipt(currentSnapshot, central)) { + && matchesAcknowledgedCatalogReceipt(currentSnapshot, central) + && this._isValidCentralPayload(central)) { if (!await this._markPayloadClean(sessionKey, receipt)) { return { session: sessionKey, status: 'retry', reason: 'superseded' }; } - return replayedRevision === undefined - ? { session: sessionKey, status: 'skipped', reason: 'synchronized' } - : { session: sessionKey, status: 'succeeded', reason: 'pendingReplayed', sourceRevision: replayedRevision }; + 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)) { + return { session: sessionKey, status: 'retry', reason: 'superseded' }; + } + return { session: sessionKey, status: 'succeeded', reason: 'synchronized', sourceRevision: outcome.sourceRevision }; } if (token.isCancellationRequested) { @@ -321,6 +412,16 @@ export class AgentHostCatalogReconciliationService extends Disposable { } } + 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, @@ -438,7 +539,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { } private _selectBatch(sessions: readonly IRegisteredSession[], cursor: string | undefined): readonly IRegisteredSession[] { - const start = cursor === undefined ? 0 : Math.max(0, sessions.findIndex(session => session.session.toString() > cursor)); + 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); } @@ -449,13 +550,20 @@ export class AgentHostCatalogReconciliationService extends Disposable { } private _scheduleNextPass(): void { - if (!this._periodic || this._cancellation.token.isCancellationRequested) { + 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(); - }, this._intervalMs); + }, delay); } private _logOutcomes(outcomes: readonly AgentHostCatalogReconciliationOutcome[]): void { diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 9adf77fa125e49..2ab48b6db20911 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -3,6 +3,7 @@ * 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 { 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'; @@ -10,7 +11,7 @@ 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, agentHostCatalogGitValidator } from './agentHostCatalogProjection.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'; @@ -127,20 +128,25 @@ export class AgentHostCatalogSourceResolver { ref.dispose(); } const metadata = { ...persisted, ...metadataOverrides }; - const chatMetadata = new Map(await Promise.all(state.chats.map(async chat => { - const ref = await this._dependencies.tryOpenDatabase?.(URI.parse(chat.uri)); - if (!ref) { - return [chat.uri, undefined] as const; - } + const chatMetadataLimiter = new Limiter> | undefined]>(4); + const chatMetadata = new Map(await Promise.all(state.chats.map(chat => chatMetadataLimiter.queue(async () => { try { - return [chat.uri, await ref.object.getMetadataObject({ - [SESSION_CUSTOM_TITLE_KEY]: true, - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: true, - })] as const; - } finally { - ref.dispose(); + const ref = await this._dependencies.tryOpenDatabase?.(URI.parse(chat.uri)); + if (!ref) { + return [chat.uri, undefined] as const; + } + try { + return [chat.uri, await ref.object.getMetadataObject({ + [SESSION_CUSTOM_TITLE_KEY]: true, + [SESSION_CUSTOM_TITLE_SOURCE_KEY]: true, + })] as const; + } finally { + ref.dispose(); + } + } catch { + return [chat.uri, undefined] as const; } - }))); + })))); const persistedTitle = sessionMetadata.title.read(metadata); const persistedTitleSource = sessionMetadata.titleSource.read(metadata); const defaultChat = state.chats.find(chat => chat.kind === 'default'); @@ -453,7 +459,7 @@ function readPersistedChanges(value: string | undefined): ChangesSummary | undef return undefined; } try { - return JSON.parse(value) as ChangesSummary; + return agentHostCatalogChangesValidator.validate(JSON.parse(value)).content; } catch { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index c8cab071a9c10e..f893c223cfd9af 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -60,6 +60,13 @@ export interface IAgentHostDatabaseSessionsV2Exclusion { 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; @@ -141,8 +148,8 @@ export interface IAgentHostDatabase extends IDisposable { markSessionsV2Excluded(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise; /** Durably records multiple non-deletion exclusions in one transaction. */ markSessionsV2ExcludedBatch?(exclusions: readonly IAgentHostDatabaseSessionsV2Exclusion[]): Promise; - /** Atomically excludes and removes a current v2 identity. */ - excludeSessionV2(exclusion: 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. */ @@ -180,8 +187,8 @@ export interface IAgentHostDatabase extends IDisposable { 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. */ - reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): 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. */ @@ -662,11 +669,19 @@ export class AgentHostDatabase implements IAgentHostDatabase { }); } - excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise { + 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), @@ -677,8 +692,9 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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) { - await this._rollback(database, error, `Failed to exclude sessions_v2 identity ${exclusion.session}`); + return this._rollback(database, error, `Failed to exclude sessions_v2 identity ${exclusion.session}`); } }); } @@ -887,7 +903,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { }); } - async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { + async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { return this._transactionSequencer.queue(async () => { const database = await this._ensureDatabase(); await exec(database, 'BEGIN IMMEDIATE'); @@ -895,26 +911,53 @@ export class AgentHostDatabase implements IAgentHostDatabase { await run(database, `UPDATE sessions_v2 SET provider = ?, start_time = ?, - external = ?, - registration_source = ? + 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) { - await this._rollback(database, error, `Failed to reconcile sessions_v2 identity ${session} from legacy`); + 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(); @@ -1104,7 +1147,8 @@ export class AgentHostDatabase implements IAgentHostDatabase { } async getSessionChatCatalog(session: string): Promise { - const rows = await all(await this._ensureDatabase(), `SELECT + 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, @@ -1117,22 +1161,23 @@ export class AgentHostDatabase implements IAgentHostDatabase { 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 }), - })), - }; + 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 { diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index 414d15b7e44d4d..0a6094a4ccef1f 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -67,6 +67,13 @@ export class AgentHostPeerChatStore { result = reconciled.status === 'available' ? reconciled.entries : undefined; return; } + if (legacy === undefined) { + try { + await this._publishCompatibilityState(session, central, catalog.revision); + } 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; diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index 8bbdbe93f8d81e..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; } @@ -257,6 +259,10 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen 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; @@ -480,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), ); } @@ -515,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 { @@ -554,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); } @@ -566,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(); @@ -589,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); @@ -604,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 index 3303089048d4be..d366a8e88edabe 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts @@ -58,11 +58,12 @@ export interface IAgentHostSessionsV2MigrationReport { 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'; +type AgentHostSessionsV2MigrationStatus = 'skipped' | 'synchronized' | 'excluded' | 'incomplete' | 'failed' | 'staleExclusion'; interface IAgentHostSessionsV2MigrationOutcome { readonly status: AgentHostSessionsV2MigrationStatus; @@ -139,6 +140,7 @@ export class AgentHostSessionsV2MigrationService { 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 @@ -161,10 +163,11 @@ export class AgentHostSessionsV2MigrationService { 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) { + if (report.incomplete === 0 && report.failed === 0 && report.staleExclusions === 0) { if (!wasBackfilled) { await this._database.markSessionsV2Backfilled(provider, AGENT_HOST_CATALOG_PAYLOAD_VERSION); } @@ -199,15 +202,15 @@ export class AgentHostSessionsV2MigrationService { } const permanentExclusion = getPermanentExclusion(candidate); if (permanentExclusion) { - await this._exclude(provider, candidate, permanentExclusion); - return { status: 'excluded' }; + 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 && !this._registrationsEqual(candidate.current, candidate.legacy) + 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)) { - await this._database.reconcileSessionV2RegistrationFromLegacy(session, candidate.legacy); - effectiveCandidate = { ...candidate, current: candidate.legacy }; + const reconciled = await this._database.reconcileSessionV2RegistrationFromLegacy(session, candidate.legacy); + effectiveCandidate = { ...candidate, current: reconciled }; if (candidate.legacy.external !== undefined && hasMatchingReceipt) { return { status: 'synchronized' }; } @@ -218,13 +221,11 @@ export class AgentHostSessionsV2MigrationService { const resolution = await resolve(effectiveCandidate); if (resolution.status === 'excluded') { - await this._exclude(provider, candidate, resolution); - return { status: 'excluded' }; + return { status: await this._exclude(provider, effectiveCandidate, resolution) }; } if (resolution.status === 'incomplete') { - if (enumerated && !candidate.provider && !candidate.catalog && (candidate.current || candidate.legacy)) { - await this._exclude(provider, candidate, { reason: 'providerAbsent', fingerprint: 'enumeration-v1' }); - return { status: 'excluded' }; + 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' }; } @@ -290,13 +291,21 @@ export class AgentHostSessionsV2MigrationService { } } - private async _exclude(provider: AgentProvider, candidate: IAgentHostSessionsV2Candidate, exclusion: IAgentHostSessionsV2Exclusion): Promise { - await this._database.excludeSessionV2({ + 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 { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 79339df7ed1ec7..64059b08dd5ab3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -70,7 +70,7 @@ import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.j import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts, type ISessionArtifact } from '../common/sessionArtifacts.js'; import { AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; -import { AgentHostCatalogReconciliationService, AgentHostCatalogReconciliationSourceResult } from './agentHostCatalogReconciliationService.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'; @@ -127,7 +127,7 @@ interface IRecentLocalSessionUpdate { } interface ISessionListComputation { - readonly epoch: number; + epoch: number; readonly promise: Promise; trailing?: Promise; } @@ -370,6 +370,7 @@ export interface IAgentServiceOptions { readonly debugLogsEnvironment?: IAgentHostDebugLogsEnvironment; readonly sessionResidencyLimit?: number; readonly sessionReleaseRetryMs?: number; + readonly catalogReconciliationOptions?: IAgentHostCatalogReconciliationOptions; } export interface IAgentServiceCallbacks { @@ -389,6 +390,8 @@ export interface IAgentServiceCallbacks { 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 { @@ -465,10 +468,12 @@ export class AgentService extends Disposable implements IAgentService { 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>(); @@ -477,16 +482,8 @@ export class AgentService extends Disposable implements IAgentService { 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 _isChatBacking} (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, including when the local marker cannot be persisted. */ private readonly _unpersistedChatBackings = new Set(); @@ -707,6 +704,7 @@ export class AgentService extends Disposable implements IAgentService { 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))); @@ -742,10 +740,11 @@ export class AgentService extends Disposable implements IAgentService { this._writeSessionModifiedTime(URI.parse(session), modifiedTime); } if (changes.modifiedAt !== undefined - && !this._sessionListReconciliationActive && 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), {}); @@ -798,6 +797,7 @@ export class AgentService extends Disposable implements IAgentService { () => this._listRegisteredSessions(), registered => this._resolveCatalogReconciliationSource(registered), this._logService, + options.catalogReconciliationOptions, )); this._catalogReconciliationService.schedule(); this._register(core.disposables); @@ -892,6 +892,7 @@ export class AgentService extends Disposable implements IAgentService { 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(); @@ -1127,6 +1128,7 @@ 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) { @@ -1556,8 +1558,8 @@ export class AgentService extends Disposable implements IAgentService { 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, [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, [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 }; + ? { 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; @@ -1609,6 +1611,16 @@ export class AgentService extends Disposable implements IAgentService { 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) }; @@ -1725,6 +1737,23 @@ export class AgentService extends Disposable implements IAgentService { 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); @@ -1939,12 +1968,21 @@ export class AgentService extends Disposable implements IAgentService { await Promise.all(this._providerService.getProviders().map(provider => this._awaitInitialProviderMigrationForProvider(provider))); } - private _retryInitialProviderMigrationsInBackground(): void { + private _retryInitialProviderMigrationsInBackground(shouldRetry: (provider: AgentProvider) => boolean): void { for (const provider of this._providerService.getProviders()) { - if (this._backgroundInitialMigrationRetries.has(provider.id)) { + if (!shouldRetry(provider.id) || this._backgroundInitialMigrationRetries.has(provider.id)) { continue; } - const retry = this._awaitInitialProviderMigrationForProvider(provider).then( + 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); @@ -2280,10 +2318,10 @@ export class AgentService extends Disposable implements IAgentService { } return; } - if (report.synchronized + report.excluded + report.incomplete + report.failed + report.skipped > 0) { + if (report.synchronized + report.excluded + report.incomplete + report.failed + report.staleExclusions + report.skipped > 0) { this._invalidateSessionList(); } - if (report.incomplete + report.failed > 0) { + if (report.incomplete + report.failed + report.staleExclusions > 0) { this._catalogReconciliationService.schedule(); } const untitledExternal: IAgentSessionMetadata[] = []; @@ -2303,11 +2341,13 @@ export class AgentService extends Disposable implements IAgentService { this._deferredProviderMigrations.delete(provider.id); 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'); @@ -2318,7 +2358,7 @@ export class AgentService extends Disposable implements IAgentService { 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.incomplete} incomplete, ${report.failed} failed, marker ${report.marked ? 'set' : 'not set'}`); + 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'}`); } private async _resolveSessionsV2ImportCandidate(provider: IAgent, candidate: IAgentHostSessionsV2Candidate): Promise> { @@ -2515,21 +2555,29 @@ export class AgentService extends Disposable implements IAgentService { * or in `_unpersistedChatBackings`. */ private async _isChatBacking(session: URI): Promise { - if (this._unpersistedChatBackings.has(session.toString())) { + const sessionKey = session.toString(); + if (this._unpersistedChatBackings.has(sessionKey)) { return true; } try { const ref = await this._sessionDataService.tryOpenDatabase(session); - if (!ref) { - return false; - } - try { - return !!(await ref.object.getMetadata(CHAT_BACKING_METADATA_KEY)); - } finally { - ref.dispose(); + if (ref) { + try { + if (await ref.object.getMetadata(CHAT_BACKING_METADATA_KEY)) { + return true; + } + } finally { + ref.dispose(); + } } - } catch { + } 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; } } @@ -2554,7 +2602,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]; } @@ -2594,8 +2645,6 @@ export class AgentService extends Disposable implements IAgentService { if (allRegistered.length === 0) { await this._awaitInitialProviderMigration(); allRegistered = await this._listRegisteredSessions(); - } else { - this._retryInitialProviderMigrationsInBackground(); } // External sessions that the current mode hides outright are dropped // before any provider or database read. On a large catalogue these are @@ -2608,13 +2657,15 @@ export class AgentService extends Disposable implements IAgentService { const registered = hiddenExternal.size > 0 ? allRegistered.filter(entry => !hiddenExternal.has(entry.session.toString())) : allRegistered; + 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()) || this._unpersistedChatBackings.has(session.toString())) { + if (this._stateManager.isIdleProvisionalSession(session.toString()) || await this._isCatalogBackingProjectionPending(session)) { return undefined; } return { @@ -2622,6 +2673,13 @@ export class AgentService extends Disposable implements IAgentService { 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)) { @@ -2798,7 +2856,13 @@ export class AgentService extends Disposable implements IAgentService { if (epoch !== this._registryEpoch) { const currentRegistered = await this._listRegisteredSessions(); if (!this._sameSessionRegistrations(allRegistered, currentRegistered)) { - return this._computeSessions(mode, this._registryEpoch); + 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; @@ -2813,7 +2877,8 @@ export class AgentService extends Disposable implements IAgentService { const candidate = secondBySession.get(session.session.toString()); return candidate?.provider === session.provider && candidate.external === session.external - && candidate.source === session.source; + && candidate.source === session.source + && candidate.modifiedTime === session.modifiedTime; }); } @@ -3009,7 +3074,6 @@ export class AgentService extends Disposable implements IAgentService { private readonly _announcedSurfacedKeys = new Set(); private readonly _broadcastExternalSessions = new Set(); private _sessionListReconciliation = Promise.resolve(); - private _sessionListReconciliationActive = false; /** Coalescing state for storm-driven (mode-agnostic) reconciliations. */ private _reconciliationInFlight = false; private _reconciliationDirty = false; @@ -3074,12 +3138,7 @@ export class AgentService extends Disposable implements IAgentService { } private async _runSessionListReconciliation(previousMode: AgentHostExternalSessionsMode | undefined, forceCatalogRefresh: boolean): Promise { - this._sessionListReconciliationActive = true; - try { - await this._reconcileExternalSessions(previousMode, forceCatalogRefresh); - } finally { - this._sessionListReconciliationActive = false; - } + await this._reconcileExternalSessions(previousMode, forceCatalogRefresh); } private async _reconcileExternalSessions(previousMode: AgentHostExternalSessionsMode | undefined, forceCatalogRefresh = false): Promise { @@ -3515,7 +3574,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`. - await this._persistOrderedListVisibleSessionState(session, this._creationMetadataOverrides(this._stateManager.getSessionState(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}. @@ -3841,12 +3906,15 @@ export class AgentService extends Disposable implements IAgentService { try { 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); @@ -3860,11 +3928,19 @@ export class AgentService extends Disposable implements IAgentService { this._catalogChatsFromState(state).filter(candidate => candidate.uri !== chatKey), ); } - this._sideEffects.cancelSubagentSessions(chatKey); - this._sideEffects.clearChannelTelemetry(chatKey); - this._chatContributions.disposeChatState(chatKey); - this._stateManager.removeChat(sessionKey, 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); } @@ -3874,6 +3950,32 @@ export class AgentService extends Disposable implements IAgentService { } } + 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)` → @@ -4647,6 +4749,7 @@ export class AgentService extends Disposable implements IAgentService { // 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), @@ -6736,15 +6839,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 - * `_isChatBacking` (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 { @@ -6755,14 +6855,12 @@ 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) { @@ -6772,6 +6870,22 @@ export class AgentService extends Disposable implements IAgentService { } } + 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; + } + private async _markCatalogPayloadDirty(session: string): Promise { try { await this._orchestratorDatabase.markSessionV2PayloadDirty(session); diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts index b4ec1f3f8b7dee..9f71bf40929992 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts @@ -54,7 +54,7 @@ export class SessionTitleContribution extends Disposable implements IAgentHostCh 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); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 8ead00ac0a02ad..5632e3f48eca2c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3659,6 +3659,10 @@ export class CopilotAgent extends Disposable implements IAgent { } 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) { 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/test/node/agentHostCatalogReconciliationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts index 6033d2c7f78b78..0aca50d10856a5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { Event } from '../../../../base/common/event.js'; -import { type IReference } from '../../../../base/common/lifecycle.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'; @@ -14,7 +14,7 @@ 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 } from '../../node/agentHostCatalogProjection.js'; -import { AgentHostDatabase, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabaseSessionV2Envelope } from '../../node/agentHostDatabase.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'; @@ -83,6 +83,9 @@ class RecordingCatalogDatabase extends AgentHostDatabase { failUpsert = false; failUpsertCount = 0; failMarkAll = 0; + markAllCalls = 0; + dirtyAfterUpsertCount = 0; + nonCanonicalPayloadReads = 0; constructor() { super(':memory:'); @@ -94,16 +97,52 @@ class RecordingCatalogDatabase extends AgentHostDatabase { this.failUpsertCount = Math.max(0, this.failUpsertCount - 1); throw new Error('central unavailable'); } - return super.upsertSessionV2(envelope, expectedSessionGeneration); + 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 { @@ -235,6 +274,188 @@ suite('AgentHostCatalogReconciliationService', () => { }); }); + 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: 'retry', reason: 'superseded' }], + 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'); @@ -425,6 +646,100 @@ suite('AgentHostCatalogReconciliationService', () => { ]); }); + 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'); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index fc04ecb0ed8870..68f275c39af21d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -120,6 +120,66 @@ suite('AgentHostCatalogSourceResolver', () => { }]); }); + test('bounds chat-local metadata reads and isolates open and read failures', 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'], + ])); + let active = 0; + let maximumActive = 0; + const resolver = new AgentHostCatalogSourceResolver({ + openDatabase: () => ({ + 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 }, + }, + dispose: () => { }, + }), + tryOpenDatabase: async chatUri => { + active++; + maximumActive = Math.max(maximumActive, active); + await new Promise(resolve => setTimeout(resolve, 1)); + active--; + if (chatUri.toString() === chats[2].uri) { + throw new Error('open failed'); + } + return { + object: { + getMetadataObject: async >(keys: T): Promise<{ [K in keyof T]: string | undefined }> => { + if (chatUri.toString() === chats[7].uri) { + throw new Error('read failed'); + } + return Object.fromEntries(Object.keys(keys).map(key => [key, undefined])) as { [K in keyof T]: string | undefined }; + }, + }, + dispose: () => { }, + }; + }, + isUnpersistedChatBacking: () => false, + worktreeProjectFromRepositoryRoot: () => undefined, + }); + + const result = await resolver.buildCatalogSyncRequest(session, { + ...sourceState(), + chats, + }, {}, true); + + assert.deepStrictEqual({ + maximumActive, + titles: result.data.chats.map(chat => chat.summary), + sources: result.data.chats.map(chat => chat.titleSource), + }, { + maximumActive: 4, + titles: chats.map((_, index) => `Fallback ${index}`), + sources: chats.map(() => 'user'), + }); + }); + test('uses the default chat title as the session title when no explicit session title exists', async () => { const metadata = { ...persistedMetadata() }; delete metadata[SESSION_CUSTOM_TITLE_KEY]; @@ -320,6 +380,15 @@ suite('AgentHostCatalogSourceResolver', () => { }); }); + 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); diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index d3f95cc1c9f596..d8036ec1322e5d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -8,6 +8,7 @@ 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'; @@ -326,6 +327,51 @@ suite('AgentHostDatabase sessions_v2', () => { }); }); + 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'; @@ -841,6 +887,34 @@ suite('AgentHostDatabase sessions_v2', () => { }); }); + 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'; @@ -849,6 +923,9 @@ suite('AgentHostDatabase sessions_v2', () => { session, reason: 'providerAbsent', fingerprint: 'enumeration-v1', + }, { + identity: undefined, + catalog: undefined, }); await database.registerRuntimeSession(session, { provider: 'copilot', startTime: 10, source: 'restore' }, { checkTombstone: true }); @@ -1086,6 +1163,13 @@ suite('AgentHostDatabase sessions_v2', () => { 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 = { @@ -1138,6 +1222,13 @@ suite('AgentHostDatabase sessions_v2', () => { 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'); @@ -1163,4 +1254,40 @@ suite('AgentHostDatabase sessions_v2', () => { 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 index 62a92e77c8e6dd..fa9fc3d2e65941 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -40,6 +40,14 @@ class FailingLegacyMirrorDatabase extends TestSessionDatabase { } } +class RecordingLogService extends NullLogService { + readonly errors: (string | Error)[] = []; + + override error(message: string | Error): void { + this.errors.push(message); + } +} + suite('AgentHostPeerChatStore', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -58,8 +66,8 @@ suite('AgentHostPeerChatStore', () => { await orchestrator.close(); }); - function createStore(database: TestSessionDatabase): AgentHostPeerChatStore { - return new AgentHostPeerChatStore(orchestrator, createSessionDataService(database), new NullLogService()); + function createStore(database: TestSessionDatabase, logService = new NullLogService()): AgentHostPeerChatStore { + return new AgentHostPeerChatStore(orchestrator, createSessionDataService(database), logService); } test('heals malformed metadata on the next write', async () => { @@ -248,6 +256,53 @@ suite('AgentHostPeerChatStore', () => { }); }); + 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); 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 25c3ee125319f3..a0881dd5a6c7da 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'; @@ -42,12 +42,13 @@ import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/ag 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_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 MessageAttachment } from '../../common/state/protocol/state.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, AgentHostDatabaseSessionChatCatalogReplaceResult, AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionChat, IAgentHostDatabaseSessionChatCatalog, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from '../../node/agentHostDatabase.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 } 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'; @@ -244,6 +245,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; @@ -481,11 +497,15 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); } - async excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise { + 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 { @@ -618,14 +638,19 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { } } - async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { + async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { this._beforeWrite(); this.sessionV2ReconcileAttempts++; - this._sessionV2Registrations.set(session, legacy); + 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, ...legacy }); + this._sessionsV2.set(session, { ...projection, ...reconciled }); } + return reconciled; } async getSessionV2Registration(session: string): Promise { @@ -788,12 +813,15 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { 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 { @@ -844,10 +872,14 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); } - async excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise { + 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 { @@ -948,12 +980,17 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { } } - async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { - this._sessionV2Registrations.set(session, legacy); + 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, ...legacy }); + this._sessionsV2.set(session, { ...projection, ...reconciled }); } + return reconciled; } async getSessionV2Registration(session: string): Promise { @@ -3569,6 +3606,56 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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.setMetadataValuesAndCatalogSyncSnapshot(values, snapshot); + } + } + 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 @@ -3927,6 +4014,13 @@ suite('AgentService (node dispatcher)', () => { undefined, storageResource, orchestratorDatabase, + undefined, + undefined, + { + schedule: (callback, delay) => delay >= 5 * 60 * 1000 + ? toDisposable(() => { }) + : disposableTimeout(callback, delay), + }, )); } @@ -4138,6 +4232,18 @@ suite('AgentService (node dispatcher)', () => { } 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 { const summaries = sessions.map((session): SessionSummary => { const provider = AgentSession.provider(session.session); @@ -4199,7 +4305,7 @@ suite('AgentService (node dispatcher)', () => { databaseOpens = 0; const agent = disposables.add(new CountingMetadataAgent('copilot')); registerTestAgentProvider(svc, agent); - await timeout(0); + await waitForInitialProviderMigration(svc, agent); agent.metadataCalls = []; databaseOpens = 0; @@ -4232,7 +4338,7 @@ suite('AgentService (node dispatcher)', () => { await svc.whenCatalogReconciliationIdle(); const agent = disposables.add(new CountingMetadataAgent('copilot')); registerTestAgentProvider(svc, agent); - await timeout(0); + await waitForInitialProviderMigration(svc, agent); const snapshotRead = new DeferredPromise(); const releaseSnapshot = new DeferredPromise(); @@ -4298,7 +4404,7 @@ suite('AgentService (node dispatcher)', () => { databaseOpens = 0; const agent = disposables.add(new CountingMetadataAgent('copilot')); registerTestAgentProvider(svc, agent); - await timeout(0); + await waitForInitialProviderMigration(svc, agent); agent.metadataCalls = []; databaseOpens = 0; @@ -4339,7 +4445,10 @@ suite('AgentService (node dispatcher)', () => { } orchestratorDatabase.setCatalog(centralSession, centralData(30, 'Central')); const databaseOpens: string[] = []; - const baseSessionDataService = createSessionDataService(); + 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 => { @@ -4353,7 +4462,7 @@ suite('AgentService (node dispatcher)', () => { agent.addSession('eligible', 30); agent.addSession('fallback', 25); registerTestAgentProvider(svc, agent); - await timeout(0); + await waitForInitialProviderMigration(svc, agent); await svc.whenCatalogReconciliationIdle(); agent.metadataCalls = []; databaseOpens.length = 0; @@ -4362,12 +4471,14 @@ suite('AgentService (node dispatcher)', () => { 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()], @@ -4668,7 +4779,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); @@ -4697,18 +4810,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 () => { @@ -5488,6 +5668,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(); @@ -5597,6 +5835,48 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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(); @@ -6007,6 +6287,37 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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(); @@ -6316,6 +6627,84 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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(); @@ -12359,6 +12748,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')); @@ -15541,6 +15978,115 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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; 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 a786e3f145272c..ef7e26a4125a2c 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts @@ -147,10 +147,11 @@ class TestAgentHostDatabase implements IAgentHostDatabase { this._sessionsV2Exclusions.set(`${exclusion.provider}:${exclusion.session}`, exclusion); } - async excludeSessionV2(exclusion: IAgentHostDatabaseSessionsV2Exclusion): Promise { + 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 { @@ -229,8 +230,9 @@ class TestAgentHostDatabase implements IAgentHostDatabase { return this.updateSessionExternal(updates); } - async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { + async reconcileSessionV2RegistrationFromLegacy(session: string, legacy: IAgentHostDatabaseSession): Promise { this.sessions.set(session, legacy); + return legacy; } getSessionV2Registration(session: string): Promise { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index a42d1053bf1eb3..7a88771634e253 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -12381,6 +12381,7 @@ suite('CopilotAgent', () => { // 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); @@ -12388,12 +12389,13 @@ suite('CopilotAgent', () => { 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, title, isRead }, - { reason: 'alreadyNative', marker: 'true', title: 'Legacy title', isRead: '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 }); 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 de56e5a5cb8b5b..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 @@ -103,88 +103,27 @@ Host-owned background activities remain independent of client visibility. Agent ### 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. +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 From d26ff106a5b4edf472ddff58f85da0e329bbf43f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 3 Sep 2026 13:00:29 +0200 Subject: [PATCH 20/30] agentHost: avoid claiming unopened legacy sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostCatalogProjection.ts | 1 + .../agentHostCatalogReconciliationService.ts | 105 ++-- .../node/agentHostCatalogSourceResolver.ts | 82 +-- .../node/agentHostCatalogSyncService.ts | 284 +++++++--- .../agentHost/node/agentHostDatabase.ts | 7 + .../agentHost/node/agentHostPeerChatStore.ts | 123 +++-- .../agentHostSessionsV2MigrationService.ts | 20 +- .../platform/agentHost/node/agentService.ts | 132 ++++- .../agentHost/node/sessionDataService.ts | 11 +- ...ntHostCatalogReconciliationService.test.ts | 168 +++++- .../agentHostCatalogSourceResolver.test.ts | 142 ++--- .../node/agentHostCatalogSyncService.test.ts | 220 +++++++- .../test/node/agentHostPeerChatStore.test.ts | 105 ++++ .../agentHost/test/node/agentService.test.ts | 505 +++++++++++++++++- .../test/node/agentSessionRegistry.test.ts | 1 + .../test/node/sessionDataService.test.ts | 20 + 16 files changed, 1608 insertions(+), 318 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index bbb777461e858a..29b983984cc896 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -306,6 +306,7 @@ const chatValidator = plainObject(vObj({ 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( diff --git a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts index 036dc865edb53d..f7686d2111c78e 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts @@ -8,9 +8,9 @@ import { CancellationToken, CancellationTokenSource } from '../../../base/common 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, ISessionDataService } from '../common/sessionDataService.js'; +import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot } from '../common/sessionDataService.js'; import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload, encodeAgentHostCatalogPayload, hashAgentHostCatalogPayload } from './agentHostCatalogProjection.js'; -import { AgentHostCatalogSyncResult, AgentHostCatalogSyncService, catalogLegacyMetadataMatches, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.js'; +import { AgentHostCatalogDatabaseReference, 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'; @@ -28,11 +28,22 @@ 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: 'missingDatabase' | 'providerUnavailable' | 'missingCatalog' | 'staleIncarnation' | 'superseded' | 'tombstoned' | 'cancelled' } + | { 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 { @@ -76,12 +87,11 @@ export class AgentHostCatalogReconciliationService extends Disposable { private _periodic = false; constructor( - private readonly _sessionDataService: ISessionDataService, private readonly _catalogDatabase: IAgentHostDatabase, private readonly _catalogSyncService: AgentHostCatalogSyncService, private readonly _storageService: IAgentHostStorageService, private readonly _listSessions: () => Promise, - private readonly _resolveSource: (registered: IRegisteredSession) => Promise, + private readonly _resolveSource: (registered: IRegisteredSession, database: AgentHostCatalogDatabaseReference | undefined) => Promise, private readonly _logService: ILogService, options: IAgentHostCatalogReconciliationOptions = {}, ) { @@ -272,15 +282,46 @@ export class AgentHostCatalogReconciliationService extends Disposable { if (await this._catalogDatabase.isSessionTombstoned(sessionKey)) { return { session: sessionKey, status: 'retry', reason: 'tombstoned' }; } - - const database = await this._sessionDataService.tryOpenDatabase(session); - if (!database) { - return { session: sessionKey, status: 'retry', reason: 'missingDatabase' }; - } - try { - const replay = await this._catalogSyncService.runExclusive(session, async () => { + 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 (receipt ? latestReceipt?.payloadDirty !== receipt.payloadDirty : latestReceipt !== undefined) { + if (!receiptsEqual(receipt, latestReceipt) + || await this._catalogDatabase.getSessionV2PayloadDirty(sessionKey) !== observedDirty) { return { session: sessionKey, status: 'retry', reason: 'superseded' }; } const snapshot = await database.object.getCatalogSyncSnapshot(); @@ -307,7 +348,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { } satisfies Extract : await this._replayPending(session, current, acknowledgement => database.object.acknowledgeCatalogSyncSnapshot(acknowledgement), token); if (outcome.status === 'succeeded') { - if (!await this._markPayloadClean(sessionKey, latestReceipt)) { + if (!await this._markPayloadClean(sessionKey, latestReceipt, observedDirty)) { return { session: sessionKey, status: 'retry', reason: 'superseded' }; } return outcome; @@ -317,7 +358,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { } } return undefined; - }); + })(); if (replay) { return replay; } @@ -325,7 +366,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { if (token.isCancellationRequested) { return { session: sessionKey, status: 'retry', reason: 'cancelled' }; } - const sourceResult = await this._resolveSource(registered); + const sourceResult = await this._resolveSource(registered, database); if (sourceResult.status === 'providerUnavailable') { return { session: sessionKey, status: 'retry', reason: 'providerUnavailable' }; } @@ -334,9 +375,10 @@ export class AgentHostCatalogReconciliationService extends Disposable { } const legacyMetadataMatches = await catalogLegacyMetadataMatches(database.object, sourceResult.request.legacyMetadata); const expected = encodeAgentHostCatalogPayload(sourceResult.request.data); - return await this._catalogSyncService.runExclusive(session, async synchronize => { + return await (async (): Promise => { const latestReceipt = await this._catalogDatabase.getSessionV2(sessionKey); - if (receipt ? latestReceipt?.payloadDirty !== receipt.payloadDirty : latestReceipt !== undefined) { + if (!receiptsEqual(receipt, latestReceipt) + || await this._catalogDatabase.getSessionV2PayloadDirty(sessionKey) !== observedDirty) { return { session: sessionKey, status: 'retry', reason: 'superseded' }; } const currentSnapshot = await database.object.getCatalogSyncSnapshot(); @@ -350,7 +392,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { && currentSnapshot?.payloadHash === expected.value.payloadHash && matchesAcknowledgedCatalogReceipt(currentSnapshot, central) && this._isValidCentralPayload(central)) { - if (!await this._markPayloadClean(sessionKey, receipt)) { + if (!await this._markPayloadClean(sessionKey, receipt, observedDirty)) { return { session: sessionKey, status: 'retry', reason: 'superseded' }; } return { session: sessionKey, status: 'skipped', reason: 'synchronized' }; @@ -382,7 +424,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { if (outcome.status !== 'succeeded') { return outcome; } - if (!await this._markPayloadClean(sessionKey, latestReceipt)) { + if (!await this._markPayloadClean(sessionKey, latestReceipt, observedDirty)) { return { session: sessionKey, status: 'retry', reason: 'superseded' }; } return { session: sessionKey, status: 'succeeded', reason: 'synchronized', sourceRevision: outcome.sourceRevision }; @@ -398,14 +440,12 @@ export class AgentHostCatalogReconciliationService extends Disposable { if (synchronized.status !== 'acknowledged') { return { session: sessionKey, status: 'pending', reason: synchronized.reason, sourceRevision: synchronized.sourceRevision }; } - if (!await this._markPayloadClean(sessionKey, receipt)) { + if (!await this._markPayloadClean(sessionKey, receipt, observedDirty)) { return { session: sessionKey, status: 'retry', reason: 'superseded' }; } return { session: sessionKey, status: 'succeeded', reason: 'synchronized', sourceRevision: synchronized.sourceRevision }; - }); - } finally { - database.dispose(); - } + })(); + }); } catch (error) { this._logService.warn(`[AgentHostCatalogReconciliation] Failed to reconcile ${sessionKey}`, error); return { session: sessionKey, status: 'failed', reason: 'unexpected', error: error instanceof Error ? error.message : String(error) }; @@ -427,7 +467,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { snapshot: ISessionCatalogSyncPendingSnapshot, acknowledge: (acknowledgement: ISessionCatalogSyncAcknowledgement) => Promise, token: CancellationToken, - ): Promise> { + ): Promise> { const sessionKey = session.toString(); const decoded = decodeAgentHostCatalogPayload(snapshot.payload); if (!decoded.ok || snapshot.projectionVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION) { @@ -466,7 +506,7 @@ export class AgentHostCatalogReconciliationService extends Disposable { payload: snapshot.payload, }, central?.sessionGeneration); } catch (error) { - return { session: sessionKey, status: 'failed', reason: 'centralApplyFailed', error: error instanceof Error ? error.message : String(error) }; + return { session: sessionKey, status: 'pending', reason: 'upsertFailed', sourceRevision: snapshot.sourceRevision }; } if (applyResult !== 'applied' && applyResult !== 'replayed') { return this._applyFailure(sessionKey, applyResult); @@ -496,18 +536,15 @@ export class AgentHostCatalogReconciliationService extends Disposable { return { session, status: 'failed', reason: 'centralApplyFailed', error: result }; } - private async _markPayloadClean(session: string, receipt: IAgentHostDatabaseSessionV2Receipt | undefined): Promise { - const current = receipt ?? await this._catalogDatabase.getSessionV2(session); + private async _markPayloadClean(session: string, receipt: IAgentHostDatabaseSessionV2Receipt | undefined, expectedDirty = receipt?.payloadDirty): Promise { + const current = await this._catalogDatabase.getSessionV2(session); if (!current) { return false; } - if (!receipt) { + if (expectedDirty === undefined || expectedDirty === 0) { return current.payloadDirty === 0; } - if (current.payloadDirty === 0) { - return false; - } - return this._catalogDatabase.markSessionV2PayloadClean(session, current.payloadDirty); + return this._catalogDatabase.markSessionV2PayloadClean(session, expectedDirty); } private async _ensureInitialPayloadDirtyMark(): Promise { diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 2ab48b6db20911..4be443418854ff 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -3,7 +3,6 @@ * 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 { 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'; @@ -31,26 +30,21 @@ export interface ICatalogSourceState { readonly kind: 'default' | 'peer'; readonly title?: string; readonly origin?: ChatOrigin; + readonly inheritedTurnId?: string; }[]; } export interface IAgentHostCatalogSourceResolverDependencies { - readonly openDatabase: (session: URI) => { - readonly object: { - getMetadataObject>(keys: T): Promise<{ [K in keyof T]: string | undefined }>; - }; - dispose(): void; - }; - readonly tryOpenDatabase?: (session: URI) => Promise<{ - readonly object: { - getMetadataObject>(keys: T): Promise<{ [K in keyof T]: string | undefined }>; - }; - dispose(): void; - } | undefined>; 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; } @@ -113,51 +107,30 @@ export class AgentHostCatalogSourceResolver { constructor(private readonly _dependencies: IAgentHostCatalogSourceResolverDependencies) { } - async buildCatalogSyncRequest(session: URI, state: ICatalogSourceState, metadataOverrides: Readonly>, preferPersistedMetadata: boolean): Promise { + 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 ref = this._dependencies.openDatabase(session); - let persisted: { readonly [key: string]: string | undefined }; - try { - persisted = await ref.object.getMetadataObject(metadataKeys); - } finally { - ref.dispose(); - } - const metadata = { ...persisted, ...metadataOverrides }; - const chatMetadataLimiter = new Limiter> | undefined]>(4); - const chatMetadata = new Map(await Promise.all(state.chats.map(chat => chatMetadataLimiter.queue(async () => { - try { - const ref = await this._dependencies.tryOpenDatabase?.(URI.parse(chat.uri)); - if (!ref) { - return [chat.uri, undefined] as const; - } - try { - return [chat.uri, await ref.object.getMetadataObject({ - [SESSION_CUSTOM_TITLE_KEY]: true, - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: true, - })] as const; - } finally { - ref.dispose(); - } - } catch { - return [chat.uri, undefined] as const; - } - })))); + 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 defaultChat = state.chats.find(chat => chat.kind === 'default'); - const defaultChatMetadata = defaultChat ? chatMetadata.get(defaultChat.uri) : undefined; const title = preferPersistedMetadata - ? persistedTitle ?? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_KEY] ?? state.title ?? '' - : metadataOverrides[SESSION_CUSTOM_TITLE_KEY] ?? state.title ?? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_KEY] ?? ''; - const titleSource = normalizeCatalogTitleSource( - persistedTitleSource - ?? (persistedTitle === undefined ? defaultChatMetadata?.[SESSION_CUSTOM_TITLE_SOURCE_KEY] : undefined), - ); + ? 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)) @@ -233,19 +206,17 @@ export class AgentHostCatalogSourceResolver { changes, _meta: Object.keys(meta).length > 0 ? meta : undefined, chats: state.chats.map((chat, order) => { - const local = chatMetadata.get(chat.uri); const summary = preferPersistedMetadata - ? local?.[SESSION_CUSTOM_TITLE_KEY] - || metadata[customChatTitleMetadataKey(chat.uri)] + ? metadata[customChatTitleMetadataKey(chat.uri)] || chat.title || undefined : metadataOverrides[customChatTitleMetadataKey(chat.uri)] || chat.title - || local?.[SESSION_CUSTOM_TITLE_KEY] + || metadata[customChatTitleMetadataKey(chat.uri)] || undefined; const titleSource = preferPersistedMetadata - ? local?.[SESSION_CUSTOM_TITLE_SOURCE_KEY] ?? metadata[customChatTitleSourceMetadataKey(chat.uri)] - : metadataOverrides[customChatTitleSourceMetadataKey(chat.uri)] ?? local?.[SESSION_CUSTOM_TITLE_SOURCE_KEY] ?? metadata[customChatTitleSourceMetadataKey(chat.uri)]; + ? metadata[customChatTitleSourceMetadataKey(chat.uri)] + : metadataOverrides[customChatTitleSourceMetadataKey(chat.uri)] ?? metadata[customChatTitleSourceMetadataKey(chat.uri)]; return { uri: chat.uri, order, @@ -253,6 +224,7 @@ export class AgentHostCatalogSourceResolver { summary: toCatalogSummary(summary), titleSource: normalizeCatalogTitleSource(titleSource), origin: toCatalogChatOrigin(chat.origin), + ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), }; }), }; diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts index 9a3725f1e185f9..e724d5bc5d6e47 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts @@ -6,10 +6,11 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { URI } from '../../../base/common/uri.js'; import { SequencerByKey } from '../../../base/common/async.js'; +import type { IReference } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; -import type { ISessionCatalogSyncAcknowledgement, ISessionCatalogSyncPendingSnapshot, ISessionCatalogSyncSnapshot, ISessionDataService } from '../common/sessionDataService.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, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; +import type { AgentHostDatabaseSessionV2UpsertResult, IAgentHostDatabase, IAgentHostDatabaseSessionV2, IAgentHostDatabaseSessionV2Envelope, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; const INITIAL_SOURCE_REVISION = 0; const MAX_GENERATION_RETRIES = 3; @@ -19,6 +20,8 @@ export interface IAgentHostCatalogSyncRequest { 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' }; @@ -70,102 +73,231 @@ export class AgentHostCatalogSyncService { }); } - synchronizeWithFactory(session: URI, requestFactory: () => Promise): Promise { - return this.runExclusive(session, async synchronize => { + 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()); + const result = await synchronize(await requestFactory(database)); await this._markPayloadDirty(session); return result; }); } - runExclusive(session: URI, operation: (synchronize: (request: IAgentHostCatalogSyncRequest) => Promise) => Promise): Promise { + 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 { return this._sequencer.queue( session.toString(), - () => operation(request => this._synchronizeNow(session, request)), + async () => { + const database = this._sessionDataService.openDatabase(session); + try { + return await operation(request => this._synchronizeWithDatabaseNow(session, request, database), database); + } finally { + database.dispose(); + } + }, ); } - private async _synchronizeNow(session: URI, request: IAgentHostCatalogSyncRequest): Promise { + runMigrationExclusive(session: URI, operation: ( + database: AgentHostCatalogDatabaseReference | undefined, + synchronize: (request: IAgentHostCatalogSyncRequest, validate?: () => Promise) => Promise, + ) => Promise): Promise { + 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); - const ref = this._sessionDataService.openDatabase(session); - try { - for (let attempt = 0; attempt < MAX_GENERATION_RETRIES; attempt++) { - const existing = await ref.object.getCatalogSyncSnapshot(); - let central: IAgentHostDatabaseSessionV2Receipt | 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()); + 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 sourceRevision = this._sourceRevision(existing, central, sessionGeneration, encoded.payloadHash, legacyMetadataMatches); - const snapshot = this._pendingSnapshot(sessionGeneration, sourceRevision, encoded); + const pending = await this._storePending(ref.object, request, encoded, existing, legacyMetadataMatches); + return { status: 'pending', sourceRevision: pending.sourceRevision, reason: 'upsertFailed' }; + } - 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 }; - } + 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', + }; + } - let upsertResult: AgentHostDatabaseSessionV2UpsertResult; + 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 { - upsertResult = await this._catalogDatabase.upsertSessionV2( - this._envelope(sessionKey, sessionGeneration, sourceRevision, encoded), - central?.sessionGeneration, - ); + winner = await this._catalogDatabase.getSessionV2(sessionKey); } catch (error) { - this._logService.warn(`[AgentHostCatalogSync] Failed to upsert sessions_v2 row for ${sessionKey}`, error); + this._logService.warn(`[AgentHostCatalogSync] Failed to verify newer 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 }; + if (winner?.sessionGeneration === sessionGeneration + && winner.sourceRevision > sourceRevision + && this._matchesEncodedPayload(winner, encoded)) { + return { status: 'acknowledged', sourceRevision: winner.sourceRevision }; } - - const acknowledgement: ISessionCatalogSyncAcknowledgement = { - sessionGeneration, - sourceRevision, - projectionVersion: snapshot.projectionVersion, - payloadHash: snapshot.payloadHash, - }; - if (!await ref.object.acknowledgeCatalogSyncSnapshot(acknowledgement)) { - return { status: 'pending', sourceRevision, reason: 'acknowledgementSuperseded' }; + 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 }; } - return { status: 'acknowledged', sourceRevision }; + continue; } - - const snapshot = await ref.object.getCatalogSyncSnapshot(); - return { - status: 'pending', - sourceRevision: snapshot?.sourceRevision ?? INITIAL_SOURCE_REVISION, - reason: 'generationMismatch', - }; - } finally { - ref.dispose(); + return { status: 'pending', sourceRevision, reason: result }; } + return { + status: 'pending', + sourceRevision: pendingRevision, + reason: 'conflict', + }; } private async _storePending( @@ -245,6 +377,12 @@ export class AgentHostCatalogSyncService { 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()); diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index f893c223cfd9af..42c8182c4352d9 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -203,6 +203,8 @@ export interface IAgentHostDatabase extends IDisposable { 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. */ @@ -1120,6 +1122,11 @@ export class AgentHostDatabase implements IAgentHostDatabase { }); } + 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) diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index 0a6094a4ccef1f..94ab2a212cbef7 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -8,6 +8,7 @@ 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'; @@ -36,22 +37,22 @@ export class AgentHostPeerChatStore { private readonly _logService: ILogService, ) { } - async tryRead(session: URI): Promise { - return this._readCentral(session, true); + 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): Promise { + 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 legacy = await this.tryReadLegacy(session); + const legacy = await this.tryReadLegacy(session, false, database); if (!catalog) { if (legacy === undefined) { return; } - const replaceResult = await this._replaceCentral(session, legacy, undefined); + const replaceResult = await this._replaceCentral(session, legacy, undefined, true, database); if (replaceResult === 'conflict') { continue; } @@ -63,13 +64,13 @@ export class AgentHostPeerChatStore { } const central = this._entriesFromCatalog(catalog.chats); if (catalog.legacyMirroredRevision !== catalog.revision) { - const reconciled = await this._reconcileUnmirroredCatalog(session); + 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); + 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()}`); } @@ -80,7 +81,7 @@ export class AgentHostPeerChatStore { } } if (legacy !== undefined && JSON.stringify(legacy) !== JSON.stringify(central)) { - const replaceResult = await this._replaceCentral(session, legacy, catalog.revision); + const replaceResult = await this._replaceCentral(session, legacy, catalog.revision, true, database); if (replaceResult === 'conflict') { continue; } @@ -92,7 +93,7 @@ export class AgentHostPeerChatStore { } const local = await this.readLocalChatMetadata(central); if (JSON.stringify(local) !== JSON.stringify(central)) { - const replaceResult = await this._replaceCentral(session, local, catalog.revision); + const replaceResult = await this._replaceCentral(session, local, catalog.revision, true, database); if (replaceResult === 'conflict') { continue; } @@ -124,8 +125,8 @@ export class AgentHostPeerChatStore { * 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): Promise { - const ref = await this._sessionDataService.tryOpenDatabase(session); + async tryReadLegacy(session: URI, batched = false, database?: AgentHostCatalogDatabaseReference): Promise { + const ref = database ?? await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return undefined; } @@ -141,7 +142,9 @@ export class AgentHostPeerChatStore { this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(error)}`); return undefined; } finally { - ref.dispose(); + if (!database) { + ref.dispose(); + } } } @@ -154,6 +157,19 @@ export class AgentHostPeerChatStore { 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 => { @@ -264,29 +280,41 @@ export class AgentHostPeerChatStore { } } - private async _replaceCentral(session: URI, updated: readonly IPersistedPeerChat[], expectedRevision: number | undefined): Promise<'applied' | 'conflict' | 'sessionUnavailable'> { - const result = await this._database.replaceSessionChatCatalog(session.toString(), updated.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 } : {}), - })), expectedRevision); + 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'; } - try { - await this._publishCompatibilityState(session, updated, result.revision); - } catch (error) { - this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); + 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 async _publishCompatibilityState(session: URI, initialEntries: readonly IPersistedPeerChat[], initialRevision: number): Promise { + 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) { @@ -300,7 +328,7 @@ export class AgentHostPeerChatStore { revision = current.revision; continue; } - if (await this._writeLegacyMirror(session, entries, revision)) { + if (await this._writeLegacyMirror(session, entries, revision, database)) { return; } const superseding = await this._database.getSessionChatCatalog(session.toString()); @@ -318,7 +346,7 @@ export class AgentHostPeerChatStore { }); } - private async _reconcileUnmirroredCatalog(session: URI): Promise< + private async _reconcileUnmirroredCatalog(session: URI, database?: AgentHostCatalogDatabaseReference): Promise< | { readonly status: 'available'; readonly entries: IPersistedPeerChat[]; readonly revision: number } | { readonly status: 'missingCatalog' } | { readonly status: 'sessionUnavailable' } @@ -332,11 +360,17 @@ export class AgentHostPeerChatStore { if (catalog.legacyMirroredRevision === catalog.revision) { return { status: 'available', entries: central, revision: catalog.revision }; } - const legacy = await this.tryReadLegacy(session); + const legacyState = database + ? { databaseExists: true, entries: await this.tryReadLegacy(session, true, database) } + : 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 && JSON.stringify(legacy) !== JSON.stringify(base)) { const merged = this._mergeLegacyChanges(base, central, legacy); - const replaceResult = await this._replaceCentral(session, merged, catalog.revision); + const replaceResult = await this._replaceCentral(session, merged, catalog.revision, false); if (replaceResult === 'conflict') { continue; } @@ -347,10 +381,15 @@ export class AgentHostPeerChatStore { 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); + 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()}`); } @@ -358,17 +397,35 @@ export class AgentHostPeerChatStore { } } - private async _writeLegacyMirror(session: URI, entries: readonly IPersistedPeerChat[], revision: number): Promise { + private async _writeLegacyMirror(session: URI, entries: readonly IPersistedPeerChat[], revision: number, database?: AgentHostCatalogDatabaseReference): Promise { const payload = JSON.stringify(entries); - const ref = this._sessionDataService.openDatabase(session); + const ref = database ?? this._sessionDataService.openDatabase(session); try { await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, payload); } finally { - ref.dispose(); + if (!database) { + ref.dispose(); + } } return this._database.markSessionChatCatalogLegacyMirrored(session.toString(), revision, payload); } + private async _tryReadLegacyState(session: URI): Promise<{ readonly databaseExists: boolean; 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, entries: raw === undefined ? undefined : this._parse(session, raw) }; + } 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; diff --git a/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts index d366a8e88edabe..5371097cb40209 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts @@ -9,7 +9,7 @@ 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 { AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.js'; +import { AgentHostCatalogDatabaseReference, AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.js'; import { AgentHostSessionsV2ExclusionReason, IAgentHostDatabase, IAgentHostDatabaseSession, IAgentHostDatabaseSessionsV2Exclusion, IAgentHostDatabaseSessionOptions, IAgentHostDatabaseSessionV2Receipt } from './agentHostDatabase.js'; const IMPORT_CONCURRENCY = 4; @@ -42,8 +42,8 @@ export type AgentHostSessionsV2CandidateResolution = readonly status: 'ready'; readonly identity: IAgentHostDatabaseSessionOptions; readonly external: boolean; - readonly request: IAgentHostCatalogSyncRequest; - readonly value: T; + readonly requestFactory: (database: AgentHostCatalogDatabaseReference | undefined) => Promise; + readonly valueFromRequest: (request: IAgentHostCatalogSyncRequest) => T; }; export interface IAgentHostSessionsV2ImportedCandidate { @@ -240,11 +240,21 @@ export class AgentHostSessionsV2MigrationService { await this._database.updateSessionV2External([{ session, external: resolution.external }]); } - const result = await this._catalogSyncService.synchronize(candidate.session, resolution.request); + 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 ? { imported: { session: candidate.session, external: resolution.external, value: resolution.value } } : {}), + ...(shouldReportImported && request ? { + imported: { + session: candidate.session, + external: resolution.external, + value: resolution.valueFromRequest(request), + }, + } : {}), } : { status: 'incomplete' }; } catch (error) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 64059b08dd5ab3..0c09cd63724688 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -68,8 +68,8 @@ import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreati 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 { AgentHostCatalogSyncService, IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; -import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload } from './agentHostCatalogProjection.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'; @@ -635,8 +635,6 @@ export class AgentService extends Disposable implements IAgentService { this._serverToolHost = collaborators.serverToolHost; this._catalogSyncService = new AgentHostCatalogSyncService(this._sessionDataService, this._orchestratorDatabase, this._logService); this._catalogSourceResolver = new AgentHostCatalogSourceResolver({ - openDatabase: session => this._sessionDataService.openDatabase(session), - tryOpenDatabase: session => this._sessionDataService.tryOpenDatabase(session), isUnpersistedChatBacking: session => this._unpersistedChatBackings.has(session.toString()), worktreeProjectFromRepositoryRoot, }); @@ -790,12 +788,11 @@ 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._sessionDataService, this._orchestratorDatabase, this._catalogSyncService, this._storageService, () => this._listRegisteredSessions(), - registered => this._resolveCatalogReconciliationSource(registered), + (registered, database) => this._resolveCatalogReconciliationSource(registered, database), this._logService, options.catalogReconciliationOptions, )); @@ -1782,7 +1779,7 @@ export class AgentService extends Disposable implements IAgentService { if (!summary || !state) { throw new Error(`Cannot persist list-visible state for unknown session ${sessionKey}`); } - const result = await this._catalogSyncService.synchronizeWithFactory(session, () => this._catalogSourceResolver.buildCatalogSyncRequest(session, { + const result = await this._catalogSyncService.synchronizeWithFactory(session, database => this._catalogSourceResolver.buildCatalogSyncRequest(session, { modifiedTime: Date.parse(summary.modifiedAt), title: summary.title, status: summary.status, @@ -1791,13 +1788,13 @@ export class AgentService extends Disposable implements IAgentService { changes: summary.changes, meta: summary._meta, chats: chatsOverride ?? this._catalogChatsFromState(state), - }, metadataOverrides, false)); + }, metadataOverrides, false, database)); if (result.status === 'pending') { this._logService.warn(`[AgentService] Catalog synchronization for ${sessionKey} remains pending: ${result.reason}`); } } - private async _resolveCatalogReconciliationSource(registered: IRegisteredSession): Promise { + private async _resolveCatalogReconciliationSource(registered: IRegisteredSession, database: AgentHostCatalogDatabaseReference | undefined): Promise { const agent = this._providerService.getProvider(registered.provider); if (!agent) { return { status: 'providerUnavailable' }; @@ -1807,13 +1804,26 @@ export class AgentService extends Disposable implements IAgentService { if (!metadata) { return { status: 'providerUnavailable' }; } - const peers = await this._readOrMigrateLegacyPeerChatCatalog(agent, registered.session); + let status = metadata.status ?? SessionStatus.Idle; + let metadataFallbacks: Readonly> = {}; + 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 = this._catalogMetadataFallbacks(decoded.value.data); + } + } return { status: 'available', request: await this._catalogSourceResolver.buildCatalogSyncRequest(registered.session, { modifiedTime: metadata.modifiedTime, title: metadata.summary, - status: metadata.status ?? SessionStatus.Idle, + status, project: metadata.project ? { uri: metadata.project.uri.toString(), displayName: metadata.project.displayName } : undefined, workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()) ?? [], changes: metadata.changes, @@ -1828,9 +1838,10 @@ export class AgentService extends Disposable implements IAgentService { uri: peer.uri, kind: 'peer' as const, origin: peer.origin, + inheritedTurnId: peer.inheritedTurnId, })), ], - }, {}, true), + }, {}, true, database, metadataFallbacks), }; } @@ -1858,6 +1869,7 @@ export class AgentService extends Disposable implements IAgentService { kind: state.defaultChat === chat.resource || isDefaultChatUri(chat.resource) ? 'default' : 'peer', title: chat.title, origin: chat.origin, + inheritedTurnId: this._stateManager.getChatInheritedTurnId(chat.resource), })); } @@ -2220,9 +2232,9 @@ export class AgentService extends Disposable implements IAgentService { } const effectiveExternal = effectiveIdentity.external; registryChanged = true; - const syncResult = await this._catalogSyncService.synchronize( + const syncResult = await this._catalogSyncService.synchronizeWithFactory( session, - await this._buildImportedCatalogSyncRequest(provider, sessionMetadata, effectiveExternal, true), + database => this._buildImportedCatalogSyncRequest(provider, sessionMetadata, effectiveExternal, true, database), ); if (syncResult.status === 'pending') { this._logService.warn(`[AgentService] Discovered session ${session.toString()} remains incomplete: ${syncResult.reason}`); @@ -2406,24 +2418,40 @@ export class AgentService extends Disposable implements IAgentService { if (external && !readSessionEhcliAdoptable(canonicalMetadata._meta) && this._isExternalSessionOlderThanMaxAge(canonicalMetadata.modifiedTime, Date.now())) { return { status: 'excluded', reason: 'staleExternal', fingerprint: String(canonicalMetadata.modifiedTime) }; } - const request = await this._buildImportedCatalogSyncRequest(provider, canonicalMetadata, external, !candidate.current && !candidate.legacy); + let existingCatalog; + try { + 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, - request, - value: request.data.summary === canonicalMetadata.summary + 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): Promise { - const peers = await this._readOrMigrateLegacyPeerChatCatalog(provider, metadata.session); + 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 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); return this._catalogSourceResolver.buildCatalogSyncRequest(metadata.session, { modifiedTime: metadata.modifiedTime, title: metadata.summary, - status: external && seedExternalRead ? (metadata.status ?? SessionStatus.Idle) | SessionStatus.IsRead : metadata.status ?? SessionStatus.Idle, + status, project: metadata.project ? { uri: metadata.project.uri.toString(), displayName: metadata.project.displayName } : undefined, workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()) ?? [], changes: metadata.changes, @@ -2438,9 +2466,62 @@ export class AgentService extends Disposable implements IAgentService { uri: peer.uri, kind: 'peer' as const, origin: peer.origin, + inheritedTurnId: peer.inheritedTurnId, })), ], - }, external && seedExternalRead ? { [AH_META_IS_READ_DB_KEY]: 'true' } : {}, true); + }, shouldSeedExternalRead ? { [AH_META_IS_READ_DB_KEY]: 'true' } : {}, true, database, + preserveCentralRead ? 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'); + const legacy = await agent.listLegacyChatBackings?.(session) ?? []; + const providerData = new Map(legacy.map(chat => [chat.uri.toString(), chat.providerData])); + const entries: IPersistedPeerChat[] = cachedPeers?.length + ? 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.length > 0) { + await this._peerChatStore.replaceForMigration(session, entries); + } + return entries; } private async _isExternalProviderChat(session: URI): Promise { @@ -5185,7 +5266,7 @@ export class AgentService extends Disposable implements IAgentService { private async _synchronizePassiveSessionMetadata(session: URI, key: string, flag: SessionStatus, set: boolean): Promise { let requestUnavailable = false; try { - const result = await this._catalogSyncService.synchronizeWithFactory(session, async () => { + const result = await this._catalogSyncService.synchronizeWithFactory(session, async database => { const sessionKey = session.toString(); const catalog = await this._orchestratorDatabase.getSessionV2(sessionKey); let request: IAgentHostCatalogSyncRequest | undefined; @@ -5204,7 +5285,7 @@ export class AgentService extends Disposable implements IAgentService { if (!request) { const registered = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); if (registered) { - const source = await this._resolveCatalogReconciliationSource(registered); + const source = await this._resolveCatalogReconciliationSource(registered, database); if (source.status === 'available') { request = source.request; } @@ -6526,11 +6607,12 @@ export class AgentService extends Disposable implements IAgentService { kind: chat.kind, title: chat.summary, origin: fromCatalogChatOrigin(chat.origin), + inheritedTurnId: chat.inheritedTurnId, })); } - private async _readOrMigrateLegacyPeerChatCatalog(agent: IAgent, session: URI): Promise { - const persisted = await this._peerChatStore.reconcileLegacy(session); + private async _readOrMigrateLegacyPeerChatCatalog(agent: IAgent, session: URI, database?: AgentHostCatalogDatabaseReference): Promise { + const persisted = await this._peerChatStore.reconcileLegacy(session, database); if (persisted !== undefined) { return persisted; } diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts index 42269284685ed9..e7662fa951d1aa 100644 --- a/src/vs/platform/agentHost/node/sessionDataService.ts +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -6,7 +6,7 @@ import { IReference, ReferenceCollection } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { IFileService } from '../../files/common/files.js'; +import { FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { AgentSession } from '../common/agent.js'; import { DEV_CONTAINER_WORKTREE_DATA_ID_PREFIX } from '../common/meta/agentDevContainerWorktreeMeta.js'; @@ -105,8 +105,13 @@ export class SessionDataService implements ISessionDataService { async tryOpenDatabase(session: URI): Promise | undefined> { const key = this._sanitizedSessionKey(session); const dbPath = URI.joinPath(this._basePath, key, SESSION_DB_FILENAME); - if (!await this._fileService.exists(dbPath)) { - return undefined; + try { + await this._fileService.resolve(dbPath, { resolveMetadata: false }); + } catch (error) { + if (toFileOperationResult(error) === FileOperationResult.FILE_NOT_FOUND) { + return undefined; + } + throw error; } return this._databases.acquire(key); } diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts index 0aca50d10856a5..75ffa12363e31e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogReconciliationService.test.ts @@ -13,7 +13,7 @@ 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 } from '../../node/agentHostCatalogProjection.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'; @@ -86,6 +86,7 @@ class RecordingCatalogDatabase extends AgentHostDatabase { markAllCalls = 0; dirtyAfterUpsertCount = 0; nonCanonicalPayloadReads = 0; + conflictingEnvelope: IAgentHostDatabaseSessionV2Envelope | undefined; constructor() { super(':memory:'); @@ -97,6 +98,13 @@ class RecordingCatalogDatabase extends AgentHostDatabase { 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--; @@ -199,7 +207,6 @@ suite('AgentHostCatalogReconciliationService', () => { status: 'available', request: { data: catalogData(session.session.path), legacyMetadata: { customTitle: session.session.path } }, }), options) => store.add(new AgentHostCatalogReconciliationService( - sessionDataService, central, sync, storage, @@ -267,13 +274,36 @@ suite('AgentHostCatalogReconciliationService', () => { catalogTitle: summaryOf((await harness.central.getSessionV2(session.session.toString()))!.payload), }, { before: { state: 'pending', hasPayload: true }, - outcomes: [{ session: 'agenthost:one', status: 'retry', reason: 'superseded' }], - converged: [{ session: 'agenthost:one', status: 'skipped', reason: 'synchronized' }], + 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'); @@ -291,7 +321,7 @@ suite('AgentHostCatalogReconciliationService', () => { sourceResolutions, catalogTitle: summaryOf((await harness.central.getSessionV2(session.session.toString()))!.payload), }, { - outcomes: [{ session: 'agenthost:one', status: 'retry', reason: 'superseded' }], + outcomes: [{ session: 'agenthost:one', status: 'succeeded', reason: 'pendingReplayed', sourceRevision: 0 }], sourceResolutions: 0, catalogTitle: 'pending', }); @@ -588,13 +618,126 @@ suite('AgentHostCatalogReconciliationService', () => { }); }); - test('reports missing session databases explicitly for retry', async () => { + test('reconciles a missing session database through the central-only path', async () => { const missing = new Set(['agenthost:missing']); const harness = await createHarness(['missing'], missing); - assert.deepStrictEqual((await harness.createService().runPass()).outcomes, [ - { session: 'agenthost:missing', status: 'retry', reason: 'missingDatabase' }, - ]); + 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 () => { @@ -808,12 +951,13 @@ suite('AgentHostCatalogReconciliationService', () => { currentTitle = 'new-title'; harness.central.failUpsertCount = 1; - const writerResult = await harness.sync.synchronize(session.session, { + 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()); @@ -826,8 +970,8 @@ suite('AgentHostCatalogReconciliationService', () => { cachedSummary: cached && summaryOf(cached.payload), payloadDirty: cached?.payloadDirty, }, { - writerResult: { status: 'pending', sourceRevision: 0, reason: 'upsertFailed' }, - firstRepair: [{ session: 'agenthost:one', status: 'retry', reason: 'superseded' }], + 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', diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index 68f275c39af21d..b39825f6406ac4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -80,47 +80,89 @@ function persistedMetadata(): Readonly> { }; } -function createResolver(metadata: Readonly>, unpersistedBacking = false, chatMetadata?: Readonly>): AgentHostCatalogSourceResolver { - return new AgentHostCatalogSourceResolver({ - openDatabase: () => ({ - 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 }, - }, - dispose: () => { }, - }), - tryOpenDatabase: async () => chatMetadata ? ({ - object: { - getMetadataObject: async >(keys: T): Promise<{ [K in keyof T]: string | undefined }> => - Object.fromEntries(Object.keys(keys).map(key => [key, chatMetadata[key]])) as { [K in keyof T]: string | undefined }, - }, - dispose: () => { }, - }) : undefined, +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('prefers chat-local titles over the downgrade-compatible session mirror', async () => { - const result = await createResolver(persistedMetadata(), false, { - [SESSION_CUSTOM_TITLE_KEY]: 'Chat-local title', - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', - }).buildCatalogSyncRequest(session, sourceState(), {}, true); + 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: 'Chat-local title', - titleSource: 'user', + summary: 'Persisted chat', + titleSource: 'agent', origin: { kind: ChatOriginKind.Fork, chat: 'agenthost-chat:source/default', turnId: 'turn-1' }, }]); }); - test('bounds chat-local metadata reads and isolates open and read failures', async () => { + 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, @@ -130,39 +172,7 @@ suite('AgentHostCatalogSourceResolver', () => { [customChatTitleMetadataKey(chat.uri), `Fallback ${index}`], [customChatTitleSourceMetadataKey(chat.uri), 'user'], ])); - let active = 0; - let maximumActive = 0; - const resolver = new AgentHostCatalogSourceResolver({ - openDatabase: () => ({ - 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 }, - }, - dispose: () => { }, - }), - tryOpenDatabase: async chatUri => { - active++; - maximumActive = Math.max(maximumActive, active); - await new Promise(resolve => setTimeout(resolve, 1)); - active--; - if (chatUri.toString() === chats[2].uri) { - throw new Error('open failed'); - } - return { - object: { - getMetadataObject: async >(keys: T): Promise<{ [K in keyof T]: string | undefined }> => { - if (chatUri.toString() === chats[7].uri) { - throw new Error('read failed'); - } - return Object.fromEntries(Object.keys(keys).map(key => [key, undefined])) as { [K in keyof T]: string | undefined }; - }, - }, - dispose: () => { }, - }; - }, - isUnpersistedChatBacking: () => false, - worktreeProjectFromRepositoryRoot: () => undefined, - }); + const resolver = createResolver(metadata); const result = await resolver.buildCatalogSyncRequest(session, { ...sourceState(), @@ -170,31 +180,26 @@ suite('AgentHostCatalogSourceResolver', () => { }, {}, true); assert.deepStrictEqual({ - maximumActive, titles: result.data.chats.map(chat => chat.summary), sources: result.data.chats.map(chat => chat.titleSource), }, { - maximumActive: 4, titles: chats.map((_, index) => `Fallback ${index}`), sources: chats.map(() => 'user'), }); }); - test('uses the default chat title as the session title when no explicit session title exists', async () => { + 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, false, { - [SESSION_CUSTOM_TITLE_KEY]: 'Default Chat Title', - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', - }).buildCatalogSyncRequest(session, sourceState(), {}, true); + const result = await createResolver(metadata).buildCatalogSyncRequest(session, sourceState(), {}, true); assert.deepStrictEqual({ summary: result.data.summary, titleSource: result.data.titleSource, }, { - summary: 'Default Chat Title', - titleSource: 'user', + summary: 'Live title', + titleSource: 'auto', }); }); @@ -258,10 +263,7 @@ suite('AgentHostCatalogSourceResolver', () => { }); test('prefers live chat titles over stale chat-local metadata during live synchronization', async () => { - const result = await createResolver(persistedMetadata(), false, { - [SESSION_CUSTOM_TITLE_KEY]: 'Stale chat-local title', - [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user', - }).buildCatalogSyncRequest(session, sourceState(), {}, false); + const result = await createResolver(persistedMetadata()).buildCatalogSyncRequest(session, sourceState(), {}, false); assert.strictEqual(result.data.chats[0].summary, 'Live chat'); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts index a30cebfc856645..a23a199d39a1b6 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c 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 } from '../../node/agentHostCatalogProjection.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'; @@ -171,6 +171,224 @@ suite('AgentHostCatalogSyncService', () => { }); }); + 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; diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index fa9fc3d2e65941..9dfa2c90593987 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -70,6 +70,111 @@ suite('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('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); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index a0881dd5a6c7da..4299ee12387b23 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -49,11 +49,11 @@ import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY } from '../../common/meta/agentDe import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.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 } from '../../node/agentHostPeerChatStore.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_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, 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'; @@ -130,7 +130,7 @@ function discoveredChat(session: URI, external = true, modifiedTime = Date.now() }; } -function createPerSessionDataService(): { readonly service: ISessionDataService; readonly database: (session: URI) => TestSessionDatabase; readonly databaseOpens: string[] } { +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 => { @@ -157,6 +157,7 @@ function createPerSessionDataService(): { readonly service: ISessionDataService; }, database, databaseOpens, + databaseIds: () => [...databases.keys()], }; } @@ -684,6 +685,9 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { 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 }); @@ -1030,6 +1034,9 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { 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 }); @@ -5807,6 +5814,283 @@ suite('AgentService (node dispatcher)', () => { )); } + 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(); @@ -5864,7 +6148,7 @@ suite('AgentService (node dispatcher)', () => { marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), }, { catalogCalls: 2, - listed: [providerOnly.toString()], + listed: [], current: { session: providerOnly.toString(), provider: 'copilot', @@ -6074,6 +6358,213 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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: [], + 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)]; + + 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 })), + }, { + summary: 'Local title', + titleSource: 'user', + isRead: false, + isArchived: false, + 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'); + 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: [], + 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); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.sessionMetadataOverrides = { + modifiedTime: 2, + summary: 'Provider title', + status: SessionStatus.Idle, + }; + 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())); + + assert.deepStrictEqual({ + outcomes: report.outcomes.map(outcome => outcome.status), + summary: data?.summary, + isRead: data?.isRead, + isArchived: data?.isArchived, + chats: data?.chats.map(chat => ({ summary: chat.summary, titleSource: chat.titleSource })), + localDatabaseIds: perSession.databaseIds(), + }, { + outcomes: ['succeeded'], + summary: 'Cached title', + isRead: true, + isArchived: true, + 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('resolves legacy NULL provenance before its single v2 registration', async () => { const database = new TransientRegistryWriteDatabase(); const perSession = createPerSessionDataService(); @@ -6540,11 +7031,11 @@ suite('AgentService (node dispatcher)', () => { const sibling = AgentSession.uri('copilot', 'verified-sibling'); const sessionData: ISessionDataService = { ...perSession.service, - openDatabase: session => { + tryOpenDatabase: async session => { if (failOneSession && session.toString() === failing.toString()) { - throw new Error('transient per-session failure'); + throw new Error('transient per-session probe failure'); } - return perSession.service.openDatabase(session); + return perSession.service.tryOpenDatabase(session); }, }; const svc = createService(database, sessionData); diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index ef7e26a4125a2c..ebaed3cbea7826 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts @@ -255,6 +255,7 @@ class TestAgentHostDatabase implements IAgentHostDatabase { 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'; } diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts index bbb8e9a14cb30d..90895472646624 100644 --- a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -72,6 +72,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')); From 92d653fa34dab172d834f66da1d7429e02a1a3d3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 3 Sep 2026 13:56:09 +0200 Subject: [PATCH 21/30] agentHost: preserve catalog fallback metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostPeerChatStore.ts | 59 +++- .../platform/agentHost/node/agentService.ts | 81 +++-- .../test/node/agentHostPeerChatStore.test.ts | 152 ++++++++++ .../agentHost/test/node/agentService.test.ts | 283 +++++++++++++++++- .../contrib/chat/common/model/chatModel.ts | 2 +- .../requestParser/chatRequestParser.test.ts | 64 ++++ 6 files changed, 604 insertions(+), 37 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index 94ab2a212cbef7..8ab428a7b19061 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -12,12 +12,15 @@ import type { AgentHostCatalogDatabaseReference } from './agentHostCatalogSyncSe 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; @@ -47,7 +50,8 @@ export class AgentHostPeerChatStore { await this._enqueue(session, async () => { while (true) { const catalog = await this._database.getSessionChatCatalog(session.toString()); - const legacy = await this.tryReadLegacy(session, false, database); + const legacyState = await this._tryReadLegacyPayload(session, false, database); + const legacy = legacyState?.entries; if (!catalog) { if (legacy === undefined) { return; @@ -80,15 +84,17 @@ export class AgentHostPeerChatStore { continue; } } - if (legacy !== undefined && JSON.stringify(legacy) !== JSON.stringify(central)) { - const replaceResult = await this._replaceCentral(session, legacy, catalog.revision, true, database); + 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 = legacy; + result = merged; return; } const local = await this.readLocalChatMetadata(central); @@ -126,6 +132,10 @@ export class AgentHostPeerChatStore { * 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; @@ -137,7 +147,7 @@ export class AgentHostPeerChatStore { if (raw === undefined) { return undefined; } - return this._parse(session, raw); + 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; @@ -209,7 +219,7 @@ export class AgentHostPeerChatStore { } async readLocalChatMetadata(entries: readonly IPersistedPeerChat[]): Promise { - const limiter = new Limiter(4); + const limiter = new Limiter(CHAT_METADATA_CONCURRENCY); return Promise.all(entries.map(entry => limiter.queue(async () => { try { return await this._readChatMetadata(entry); @@ -265,13 +275,22 @@ export class AgentHostPeerChatStore { reconciledEntries = reconciled.entries; } const central = catalog ? this._entriesFromCatalog(catalog.chats) : undefined; - const legacy = reconciledEntries ? undefined : await this.tryReadLegacy(session); + 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 current = reconciledEntries ?? legacy ?? central ?? []; + 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') { @@ -318,7 +337,8 @@ export class AgentHostPeerChatStore { let entries = initialEntries; let revision = initialRevision; while (true) { - await Promise.all(entries.map(entry => this._writeChatMetadata(entry))); + 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; @@ -360,15 +380,16 @@ export class AgentHostPeerChatStore { 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, entries: await this.tryReadLegacy(session, true, 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 && JSON.stringify(legacy) !== JSON.stringify(base)) { + 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') { @@ -410,14 +431,18 @@ export class AgentHostPeerChatStore { return this._database.markSessionChatCatalogLegacyMirrored(session.toString(), revision, payload); } - private async _tryReadLegacyState(session: URI): Promise<{ readonly databaseExists: boolean; readonly entries: IPersistedPeerChat[] | undefined }> { + 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, entries: raw === undefined ? undefined : this._parse(session, raw) }; + 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 }; @@ -529,15 +554,19 @@ export class AgentHostPeerChatStore { })); } - private _parse(session: URI, raw: string): IPersistedPeerChat[] { + 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 < parsed.length; index++) { + 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`); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 0c09cd63724688..f487d3d5e487d5 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -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'; @@ -1806,6 +1806,9 @@ export class AgentService extends Disposable implements IAgentService { } 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); @@ -1815,7 +1818,9 @@ export class AgentService extends Disposable implements IAgentService { 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 = this._catalogMetadataFallbacks(decoded.value.data); + metadataFallbacks = hasLiveState ? {} : this._catalogMetadataFallbacks(decoded.value.data); + centralMeta = hasLiveState ? undefined : decoded.value.data._meta; + meta = { ...centralMeta, ...metadata._meta }; } } return { @@ -1827,7 +1832,7 @@ export class AgentService extends Disposable implements IAgentService { 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(metadata._meta, undefined) : metadata._meta, + meta: registered.external ? withSessionMultiRootMetadata(meta, undefined) : meta, chats: [ { uri: buildDefaultChatUri(registered.session), @@ -2441,6 +2446,7 @@ export class AgentService extends Disposable implements IAgentService { 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 @@ -2448,6 +2454,9 @@ export class AgentService extends Disposable implements IAgentService { 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, @@ -2455,7 +2464,7 @@ export class AgentService extends Disposable implements IAgentService { project: metadata.project ? { uri: metadata.project.uri.toString(), displayName: metadata.project.displayName } : undefined, workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()) ?? [], changes: metadata.changes, - meta: external ? withSessionMultiRootMetadata(metadata._meta, undefined) : metadata._meta, + meta: external ? withSessionMultiRootMetadata(meta, undefined) : meta, chats: [ { uri: buildDefaultChatUri(metadata.session), @@ -2470,7 +2479,7 @@ export class AgentService extends Disposable implements IAgentService { })), ], }, shouldSeedExternalRead ? { [AH_META_IS_READ_DB_KEY]: 'true' } : {}, true, database, - preserveCentralRead ? this._catalogMetadataFallbacks(existingCatalogData) : {}); + preserveCentralMetadata ? this._catalogMetadataFallbacks(existingCatalogData) : {}); } private _catalogMetadataFallbacks(data: AgentHostCatalogData): Readonly> { @@ -2502,9 +2511,17 @@ export class AgentService extends Disposable implements IAgentService { } const cached = await this._readCachedChatCatalog(session); const cachedPeers = cached?.filter(chat => chat.kind === 'peer'); - const legacy = await agent.listLegacyChatBackings?.(session) ?? []; - const providerData = new Map(legacy.map(chat => [chat.uri.toString(), chat.providerData])); - const entries: IPersistedPeerChat[] = cachedPeers?.length + 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 { @@ -2514,13 +2531,14 @@ export class AgentService extends Disposable implements IAgentService { ...(chat.inheritedTurnId !== undefined ? { inheritedTurnId: chat.inheritedTurnId } : {}), }; }) - : legacy.map(chat => ({ + : legacy?.map(chat => ({ uri: chat.uri.toString(), ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}), })); - if (entries.length > 0) { - await this._peerChatStore.replaceForMigration(session, entries); + if (entries === undefined) { + return []; } + await this._peerChatStore.replaceForMigration(session, entries); return entries; } @@ -6565,8 +6583,23 @@ export class AgentService extends Disposable implements IAgentService { /** Restores authoritative central peer membership after importing cooling-period legacy changes. */ private async _restorePeerChats(agent: IAgent, session: URI): Promise { - const entries = await this._readOrMigrateLegacyPeerChatCatalog(agent, session); - await this._restorePeerChatsFromCatalog(session, entries, await this._readCachedChatCatalog(session)); + 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 } : {}), + }))); + } + await this._restorePeerChatsFromCatalog(session, entries, cached); await this._persistOrderedListVisibleSessionState(session, {}); } @@ -6616,17 +6649,20 @@ export class AgentService extends Disposable implements IAgentService { if (persisted !== undefined) { return persisted; } - const cached = await this._readCentralChatCatalog(session); + 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 } : {}), })); - const legacy = await agent.listLegacyChatBackings?.(session).catch(error => { + 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); - return []; - }) ?? []; + 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); @@ -6636,7 +6672,16 @@ export class AgentService extends Disposable implements IAgentService { await this._peerChatStore.replace(session, peers); return peers; } - const legacy = await agent.listLegacyChatBackings?.(session) ?? []; + 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 } : {}), diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index 9dfa2c90593987..6dc41855e18c1c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -9,6 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c 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'; @@ -48,6 +49,24 @@ class RecordingLogService extends NullLogService { } } +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(); @@ -148,6 +167,53 @@ suite('AgentHostPeerChatStore', () => { }); }); + 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; @@ -361,6 +427,92 @@ suite('AgentHostPeerChatStore', () => { }); }); + 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); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 4299ee12387b23..1096c44c61596d 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -6148,7 +6148,7 @@ suite('AgentService (node dispatcher)', () => { marker: await database.isSessionsV2Backfilled('copilot', AGENT_HOST_CATALOG_PAYLOAD_VERSION), }, { catalogCalls: 2, - listed: [], + listed: [providerOnly.toString()], current: { session: providerOnly.toString(), provider: 'copilot', @@ -6416,6 +6416,10 @@ suite('AgentService (node dispatcher)', () => { 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({ @@ -6428,7 +6432,9 @@ suite('AgentService (node dispatcher)', () => { }); const svc = createService(database, perSession.service); const agent = disposables.add(new DirectImportAgent('copilot')); - agent.catalog = [metadata(session)]; + agent.catalog = [metadata(session, { + git: { branchName: 'current-provider' }, + })]; await (svc as unknown as { _ensureSessionsV2Imported(provider: IAgent, force: boolean): Promise })._ensureSessionsV2Imported(agent, true); @@ -6438,12 +6444,14 @@ suite('AgentService (node dispatcher)', () => { 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' }], }); }); @@ -6453,6 +6461,19 @@ suite('AgentService (node dispatcher)', () => { 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, @@ -6465,6 +6486,7 @@ suite('AgentService (node dispatcher)', () => { 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' }, @@ -6472,11 +6494,22 @@ suite('AgentService (node dispatcher)', () => { }, 'external-generation', 0), undefined), 'applied'); await database.markSessionV2PayloadDirty(session.toString()); const svc = createService(database, perSession.service); - const agent = disposables.add(new DirectImportAgent('copilot')); + 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); @@ -6485,12 +6518,17 @@ suite('AgentService (node dispatcher)', () => { _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(), }, { @@ -6498,6 +6536,13 @@ suite('AgentService (node dispatcher)', () => { 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' }, @@ -6563,6 +6608,195 @@ suite('AgentService (node dispatcher)', () => { }, 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 () => { @@ -15087,6 +15321,49 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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; 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'); From feffc55c51dcf6aa406a0ee1ce12d6df2c0e4f7a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 3 Sep 2026 14:16:10 +0200 Subject: [PATCH 22/30] agentHost: fence catalog sync during deletion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostCatalogReconciliationService.ts | 5 +- .../node/agentHostCatalogSyncService.ts | 54 ++++++++++++- .../platform/agentHost/node/agentService.ts | 61 ++++++++++----- .../node/agentHostCatalogSyncService.test.ts | 32 ++++++++ .../agentHost/test/node/agentService.test.ts | 76 +++++++++++++++++++ 5 files changed, 206 insertions(+), 22 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts index f7686d2111c78e..7ed31203d845af 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts @@ -10,7 +10,7 @@ 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, AgentHostCatalogSyncResult, AgentHostCatalogSyncService, catalogLegacyMetadataMatches, IAgentHostCatalogSyncRequest, matchesAcknowledgedCatalogReceipt } from './agentHostCatalogSyncService.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'; @@ -447,6 +447,9 @@ export class AgentHostCatalogReconciliationService extends Disposable { })(); }); } 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) }; } diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts index e724d5bc5d6e47..72655b1c00e4c5 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts @@ -6,7 +6,8 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { URI } from '../../../base/common/uri.js'; import { SequencerByKey } from '../../../base/common/async.js'; -import type { IReference } from '../../../base/common/lifecycle.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'; @@ -26,6 +27,17 @@ 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. @@ -57,6 +69,7 @@ export async function catalogLegacyMetadataMatches( export class AgentHostCatalogSyncService { private readonly _sequencer = new SequencerByKey(); + private readonly _deletionFences = new Map }>(); constructor( private readonly _sessionDataService: ISessionDataService, @@ -64,6 +77,39 @@ export class AgentHostCatalogSyncService { 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); @@ -104,6 +150,9 @@ export class AgentHostCatalogSyncService { 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 () => { @@ -121,6 +170,9 @@ export class AgentHostCatalogSyncService { 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 { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index f487d3d5e487d5..da6077cf5e738c 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1698,6 +1698,9 @@ 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, { @@ -4786,26 +4789,29 @@ 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 persistedPeerChats = sessionChats.length === 0 ? await this._peerChatStore.tryRead(session) : undefined; - const worktree = await this._worktree.prepareSessionDeletion(session, sessionId); - await this._peerChatStore.beginSessionDeletion(session); + 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), @@ -4814,6 +4820,8 @@ export class AgentService extends Disposable implements IAgentService { if (provider) { chatsToDelete = [...await this._disposeSession(provider, session)]; } + await this._whenBackgroundCatalogStateWritesIdle(sessionKey); + await catalogDeletionFence.whenDrained; if (!isEphemeral) { await this._retryRegistryMutation( () => this._sessionRegistry.tombstone(session), @@ -4856,7 +4864,20 @@ export class AgentService extends Disposable implements IAgentService { ); } } finally { - this._peerChatStore.endSessionDeletion(session); + if (peerChatDeletionBegun) { + this._peerChatStore.endSessionDeletion(session); + } + catalogDeletionFence.dispose(); + } + } + + private async _whenBackgroundCatalogStateWritesIdle(sessionKey: string): Promise { + while (true) { + const writes = this._backgroundCatalogStateWrites.get(sessionKey); + if (!writes || writes.size === 0) { + return; + } + await Promise.allSettled([...writes]); } } diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts index a23a199d39a1b6..136c351ba7e20d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSyncService.test.ts @@ -606,4 +606,36 @@ suite('AgentHostCatalogSyncService', () => { 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/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 1096c44c61596d..42c37f74cf29b1 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3789,6 +3789,82 @@ suite('AgentService (node dispatcher)', () => { }); }); + 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' }); From 0cec7ddf2db0620b174e3061f567cb0f9f70b586 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 4 Sep 2026 07:49:25 +0200 Subject: [PATCH 23/30] agentHost: avoid title metadata reads on restore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessionTitle/sessionTitleContribution.ts | 8 +++---- .../node/localCommands/renameLocalCommand.ts | 2 ++ .../test/node/chatContributions.test.ts | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts index 9f71bf40929992..4fbc5fef96c67b 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionTitle/sessionTitleContribution.ts @@ -74,6 +74,10 @@ 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 { + if (restored.title !== undefined) { + return restored; + } + const chatRef = await this._sessionDataService.tryOpenDatabase(URI.parse(context.chat)); if (chatRef) { try { @@ -87,10 +91,6 @@ export class SessionTitleContribution extends Disposable implements IAgentHostCh chatRef.dispose(); } } - if (restored.title !== undefined) { - return restored; - } - const ref = await this._sessionDataService.tryOpenDatabase(URI.parse(context.session)); if (!ref) { return restored; diff --git a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts index ebcde2f692c180..11277d5564ad98 100644 --- a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts @@ -45,6 +45,8 @@ export class RenameLocalCommand extends Disposable implements ILocalChatCommand if (chatTarget) { this._context.updateChatTitle(sessionChannel, chatTarget, title); this._context.markTitleRenamed(sessionChannel, chatTarget, title); + this._context.persistSessionFlag(chatTarget, SESSION_CUSTOM_TITLE_KEY, title); + this._context.persistSessionFlag(chatTarget, SESSION_CUSTOM_TITLE_SOURCE_KEY, AGENT_HOST_TITLE_SOURCE_USER); 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/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index accf984d97ba3a..b06cd9851354d6 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -2212,4 +2212,27 @@ suite('AgentHostChatContributions', () => { title: 'Chat-local title', }); }); + + test('accepts a cached chat title without reading title metadata', async () => { + const contributions = createBuiltInContributions(disposables); + const chat = buildChatUri(contributions.session, 'peer'); + let metadataReads = 0; + const getMetadata = contributions.database.getMetadata.bind(contributions.database); + contributions.database.getMetadata = async key => { + metadataReads++; + return getMetadata(key); + }; + + try { + assert.deepStrictEqual({ + restored: await contributions.service.hydrateChat({ session: contributions.session, chat }, { title: 'Cached title' }), + metadataReads, + }, { + restored: { title: 'Cached title' }, + metadataReads: 0, + }); + } finally { + contributions.database.getMetadata = getMetadata; + } + }); }); From c5ac2c4da83df9d562d1676b5d104d7706285386 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 4 Sep 2026 08:45:58 +0200 Subject: [PATCH 24/30] build: raise class fields checker heap limit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 50d2e019695738..b6fbb1502b8458 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "tsec-compile-check": "node --max-old-space-size=8192 node_modules/tsec/bin/tsec -p src/tsconfig.tsec.json", "vscode-dts-compile-check": "tsc --project src/tsconfig.vscode-dts.json && tsc --project src/tsconfig.vscode-proposed-dts.json", "valid-layers-check": "node build/checker/layersChecker.ts && node build/checker/layersTypeCheck.ts", - "define-class-fields-check": "node build/lib/propertyInitOrderChecker.ts && tsc --project src/tsconfig.defineClassFields.json", + "define-class-fields-check": "node --max-old-space-size=8192 build/lib/propertyInitOrderChecker.ts && tsc --project src/tsconfig.defineClassFields.json", "update-distro": "node build/npm/update-distro.ts", "export-policy-data": "node build/lib/policies/exportPolicyData.ts", "web": "echo 'npm run web' is replaced by './scripts/code-server' or './scripts/code-web'", From 0f605424902269dc6acd45bcbfc679dbcb014126 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 4 Sep 2026 11:34:46 +0200 Subject: [PATCH 25/30] agentHost: stabilize passive metadata restart test Ensure the test performs an actual read-state transition and waits for the corresponding fresh summary notification before restarting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/node/e2e/suites/sessionPersistenceSuite.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 29de35824019d5..91ecc540c8651e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -208,7 +208,9 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) await restartAndInitialize(`archive-unrestored-reconnect-${config.provider}`, workspace); await context.client.call('subscribe', { channel: ROOT_STATE_URI }); const before = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); - assert.strictEqual(before.items.some(item => item.resource === sessionUri), true); + const beforeSession = before.items.find(item => item.resource === sessionUri); + assert.ok(beforeSession); + const isRead = (beforeSession.status & SessionStatus.IsRead) === 0; context.client.clearReceived(); context.client.dispatch({ channel: sessionUri, @@ -220,15 +222,17 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) && (notification.params as SessionSummaryChangedParams).session === sessionUri && (((notification.params as SessionSummaryChangedParams).changes.status ?? 0) & SessionStatus.IsArchived) !== 0, ); + context.client.clearReceived(); context.client.dispatch({ channel: sessionUri, clientSeq: 2, - action: { type: ActionType.SessionIsReadChanged, isRead: true }, + action: { type: ActionType.SessionIsReadChanged, isRead }, }); 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, + && (notification.params as SessionSummaryChangedParams).changes.status !== undefined + && ((((notification.params as SessionSummaryChangedParams).changes.status ?? 0) & SessionStatus.IsRead) !== 0) === isRead, ); await restartAndInitialize(`archive-unrestored-verify-${config.provider}`, workspace); @@ -242,7 +246,7 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) }, { restored: true, isArchived: true, - isRead: true, + isRead, }); }); From c607b06d6dbec2888b4ff59bd366acfd883919e3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 4 Sep 2026 19:31:55 +0200 Subject: [PATCH 26/30] agentHost: avoid redundant peer metadata writes Only rewrite chat-local compatibility metadata for added or changed peers on normal mutations, while retaining full repair for legacy imports and stale mirrors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostPeerChatStore.ts | 48 ++++-- .../test/node/agentHostPeerChatStore.test.ts | 153 ++++++++++++++++++ 2 files changed, 187 insertions(+), 14 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index 8ab428a7b19061..66654730564345 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -29,6 +29,13 @@ export interface IPersistedPeerChat { readonly inheritedTurnId?: string; } +interface IReplaceCentralOptions { + readonly publishCompatibility?: boolean; + readonly database?: AgentHostCatalogDatabaseReference; + readonly previousEntries?: readonly IPersistedPeerChat[]; + readonly legacyMergeBase?: readonly IPersistedPeerChat[]; +} + export class AgentHostPeerChatStore { private readonly _writes = new Map>(); @@ -56,7 +63,7 @@ export class AgentHostPeerChatStore { if (legacy === undefined) { return; } - const replaceResult = await this._replaceCentral(session, legacy, undefined, true, database); + const replaceResult = await this._replaceCentral(session, legacy, undefined, { database, legacyMergeBase: legacy }); if (replaceResult === 'conflict') { continue; } @@ -87,7 +94,7 @@ export class AgentHostPeerChatStore { 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); + const replaceResult = await this._replaceCentral(session, merged, catalog.revision, { database, legacyMergeBase: legacy }); if (replaceResult === 'conflict') { continue; } @@ -99,7 +106,7 @@ export class AgentHostPeerChatStore { } const local = await this.readLocalChatMetadata(central); if (JSON.stringify(local) !== JSON.stringify(central)) { - const replaceResult = await this._replaceCentral(session, local, catalog.revision, true, database); + const replaceResult = await this._replaceCentral(session, local, catalog.revision, { database }); if (replaceResult === 'conflict') { continue; } @@ -282,7 +289,10 @@ export class AgentHostPeerChatStore { continue; } } - const legacyIsCurrentMirror = catalog?.legacyMirroredPayload !== undefined && legacyState?.raw === catalog.legacyMirroredPayload; + const legacyIsCurrentMirror = central !== undefined + && catalog?.legacyMirroredPayload !== undefined + && legacyState?.raw === catalog.legacyMirroredPayload + && catalog.legacyMirroredPayload === JSON.stringify(central); const base = catalog && legacy !== undefined && !legacyIsCurrentMirror ? this._parseLegacyMirrorBase(session, catalog.legacyMirroredPayload) : undefined; @@ -292,14 +302,17 @@ export class AgentHostPeerChatStore { ?? central ?? []; const updated = this._parse(session, JSON.stringify(mutate(current))); - const result = await this._replaceCentral(session, updated, catalog?.revision); + const result = await this._replaceCentral(session, updated, catalog?.revision, { + previousEntries: legacyIsCurrentMirror ? central : undefined, + legacyMergeBase: legacy !== undefined && !legacyIsCurrentMirror ? legacy : undefined, + }); if (result !== 'conflict') { return; } } } - private async _replaceCentral(session: URI, updated: readonly IPersistedPeerChat[], expectedRevision: number | undefined, publishCompatibility = true, database?: AgentHostCatalogDatabaseReference): Promise<'applied' | 'conflict' | 'sessionUnavailable'> { + private async _replaceCentral(session: URI, updated: readonly IPersistedPeerChat[], expectedRevision: number | undefined, options: IReplaceCentralOptions = {}): Promise<'applied' | 'conflict' | 'sessionUnavailable'> { const result = await this._database.replaceSessionChatCatalog(session.toString(), this._catalogRows(updated), expectedRevision); if (result.status !== 'applied') { if (result.status !== 'conflict') { @@ -307,9 +320,12 @@ export class AgentHostPeerChatStore { } return result.status === 'conflict' ? 'conflict' : 'sessionUnavailable'; } - if (publishCompatibility) { + if (options.legacyMergeBase && !await this._database.recordSessionChatCatalogLegacyMirrorPayload(session.toString(), result.revision, JSON.stringify(options.legacyMergeBase))) { + return 'conflict'; + } + if (options.publishCompatibility !== false) { try { - await this._publishCompatibilityState(session, updated, result.revision, database); + await this._publishCompatibilityState(session, updated, result.revision, options.database, options.previousEntries); } catch (error) { this._logService.error(error, `[AgentHostPeerChatStore] Failed to publish peer-chat compatibility state for ${session.toString()}`); } @@ -333,17 +349,23 @@ export class AgentHostPeerChatStore { })); } - private async _publishCompatibilityState(session: URI, initialEntries: readonly IPersistedPeerChat[], initialRevision: number, database?: AgentHostCatalogDatabaseReference): Promise { + private async _publishCompatibilityState(session: URI, initialEntries: readonly IPersistedPeerChat[], initialRevision: number, database?: AgentHostCatalogDatabaseReference, initialPreviousEntries?: readonly IPersistedPeerChat[]): Promise { let entries = initialEntries; let revision = initialRevision; + let previousEntries = initialPreviousEntries; while (true) { const limiter = new Limiter(CHAT_METADATA_CONCURRENCY); - await Promise.all(entries.map(entry => limiter.queue(() => this._writeChatMetadata(entry)))); + const previousByUri = previousEntries && new Map(previousEntries.map(entry => [entry.uri, entry])); + const changedEntries = previousByUri + ? entries.filter(entry => JSON.stringify(previousByUri.get(entry.uri)) !== JSON.stringify(entry)) + : entries; + await Promise.all(changedEntries.map(entry => limiter.queue(() => this._writeChatMetadata(entry)))); const current = await this._database.getSessionChatCatalog(session.toString()); if (!current) { return; } if (current.revision !== revision) { + previousEntries = entries; entries = this._entriesFromCatalog(current.chats); revision = current.revision; continue; @@ -355,6 +377,7 @@ export class AgentHostPeerChatStore { if (!superseding) { return; } + previousEntries = entries; entries = this._entriesFromCatalog(superseding.chats); revision = superseding.revision; } @@ -391,7 +414,7 @@ export class AgentHostPeerChatStore { 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); + const replaceResult = await this._replaceCentral(session, merged, catalog.revision, { publishCompatibility: false, legacyMergeBase: legacy }); if (replaceResult === 'conflict') { continue; } @@ -399,9 +422,6 @@ export class AgentHostPeerChatStore { 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) { diff --git a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts index 6dc41855e18c1c..602d313f704e6a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPeerChatStore.test.ts @@ -89,6 +89,31 @@ suite('AgentHostPeerChatStore', () => { return new AgentHostPeerChatStore(orchestrator, createSessionDataService(database), logService); } + function createPerResourceStore(): { + readonly store: AgentHostPeerChatStore; + readonly databaseFor: (resource: URI) => ConcurrentMetadataWriteDatabase; + } { + const databases = new Map(); + const databaseFor = (resource: URI) => { + const key = resource.toString(); + let database = databases.get(key); + if (!database) { + database = new ConcurrentMetadataWriteDatabase(); + databases.set(key, database); + } + return database; + }; + const service = { + ...createSessionDataService(), + openDatabase: (resource: URI) => ({ object: databaseFor(resource), dispose: () => { } }), + tryOpenDatabase: async (resource: URI) => ({ object: databaseFor(resource), dispose: () => { } }), + }; + return { + store: new AgentHostPeerChatStore(orchestrator, service, new NullLogService()), + databaseFor, + }; + } + test('migration-only membership does not create compatibility databases and mirrors after adoption', async () => { const database = new TestSessionDatabase(); let opens = 0; @@ -445,6 +470,134 @@ suite('AgentHostPeerChatStore', () => { }); }); + test('writes chat-local compatibility metadata only for changed entries during mutations', async () => { + const { store, databaseFor } = createPerResourceStore(); + const added = URI.parse(buildChatUri(session, 'added')); + await store.replace(session, [ + { uri: first.toString(), providerData: 'first' }, + { uri: second.toString(), providerData: 'second' }, + { uri: third.toString(), providerData: 'third' }, + ]); + databaseFor(first).metadataValueWrites = 0; + databaseFor(second).metadataValueWrites = 0; + databaseFor(third).metadataValueWrites = 0; + + await store.upsert(session, first, 'first'); + const reorderedWrites = databaseFor(first).metadataValueWrites + databaseFor(second).metadataValueWrites + databaseFor(third).metadataValueWrites; + await store.upsert(session, second, 'updated'); + const updatedWrites = databaseFor(first).metadataValueWrites + databaseFor(second).metadataValueWrites + databaseFor(third).metadataValueWrites - reorderedWrites; + await store.remove(session, third); + const removedWrites = databaseFor(first).metadataValueWrites + databaseFor(second).metadataValueWrites + databaseFor(third).metadataValueWrites - reorderedWrites - updatedWrites; + await store.upsert(session, added, 'added'); + const addedWrites = databaseFor(added).metadataValueWrites; + const central = await store.tryRead(session); + + assert.deepStrictEqual({ + reorderedWrites, + updatedWrites, + removedWrites, + addedWrites, + local: central && await store.readLocalChatMetadata(central), + legacy: await store.tryReadLegacy(session), + }, { + reorderedWrites: 0, + updatedWrites: 1, + removedWrites: 0, + addedWrites: 1, + local: [ + { uri: first.toString(), providerData: 'first' }, + { uri: second.toString(), providerData: 'updated' }, + { uri: added.toString(), providerData: 'added' }, + ], + legacy: [ + { uri: first.toString(), providerData: 'first' }, + { uri: second.toString(), providerData: 'updated' }, + { uri: added.toString(), providerData: 'added' }, + ], + }); + }); + + test('fully publishes legacy metadata imported during an interactive mutation', async () => { + const { store, databaseFor } = createPerResourceStore(); + await store.replace(session, [{ uri: first.toString(), providerData: 'current' }]); + await databaseFor(session).setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify([ + { uri: first.toString(), providerData: 'legacy-update' }, + ])); + + await store.upsert(session, second, 'added'); + const central = await store.tryRead(session); + const local = central && await store.readLocalChatMetadata(central); + const reconciled = await store.reconcileLegacy(session); + + assert.deepStrictEqual({ + central, + local, + reconciled, + }, { + central: [ + { uri: first.toString(), providerData: 'legacy-update' }, + { uri: second.toString(), providerData: 'added' }, + ], + local: [ + { uri: first.toString(), providerData: 'legacy-update' }, + { uri: second.toString(), providerData: 'added' }, + ], + reconciled: [ + { uri: first.toString(), providerData: 'legacy-update' }, + { uri: second.toString(), providerData: 'added' }, + ], + }); + }); + + test('fully publishes when the recorded mirror does not match central authority', async () => { + const { store, databaseFor } = createPerResourceStore(); + await store.replace(session, [{ uri: first.toString(), providerData: 'initial' }]); + const initial = await orchestrator.getSessionChatCatalog(session.toString()); + assert.ok(initial); + const updated = await orchestrator.replaceSessionChatCatalog(session.toString(), [ + { chat: first.toString(), order: 0, providerData: 'central-update' }, + ], initial.revision); + assert.strictEqual(updated.status, 'applied'); + const stalePayload = JSON.stringify([{ uri: first.toString(), providerData: 'initial' }]); + await databaseFor(session).setMetadata(PEER_CHATS_METADATA_KEY, stalePayload); + assert.strictEqual(await orchestrator.markSessionChatCatalogLegacyMirrored(session.toString(), updated.revision, stalePayload), true); + + await store.upsert(session, second, 'added'); + const central = await store.tryRead(session); + + assert.deepStrictEqual(central && await store.readLocalChatMetadata(central), [ + { uri: first.toString(), providerData: 'central-update' }, + { uri: second.toString(), providerData: 'added' }, + ]); + }); + + test('advances the merge base before retrying a failed compatibility mirror', async () => { + const database = new FailingLegacyMirrorDatabase(); + const store = createStore(database); + await store.replace(session, [ + { uri: first.toString() }, + { uri: second.toString() }, + ]); + await database.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify([{ uri: second.toString() }])); + database.failLegacyMirrors(1); + await store.reconcileLegacy(session); + const merged = await orchestrator.getSessionChatCatalog(session.toString()); + assert.ok(merged); + const concurrent = await orchestrator.replaceSessionChatCatalog(session.toString(), [ + { chat: second.toString(), order: 0 }, + { chat: first.toString(), order: 1 }, + ], merged.revision); + assert.strictEqual(concurrent.status, 'applied'); + database.failLegacyMirrors(1); + + await store.reconcileLegacy(session); + + assert.deepStrictEqual(await store.tryRead(session, false), [ + { uri: second.toString() }, + { uri: first.toString() }, + ]); + }); + test('rejects oversized imported legacy membership without changing central authority', async () => { const database = new ConcurrentMetadataWriteDatabase(); const store = createStore(database); From aebf63b814bbde1409706b03e98ed887f11c136d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 5 Sep 2026 11:05:02 +0200 Subject: [PATCH 27/30] agentHost: preserve pre-release session database schemas Recreate the released turn-delegation table alongside the catalog snapshot so databases produced by the earlier catalog-only v10 migration converge after updating. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/node/sessionDatabase.ts | 11 +++++++++-- .../agentHost/test/node/sessionDatabase.test.ts | 12 ++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 26d82cfdc3d7b0..cc300252bab494 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -166,7 +166,14 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ }, { version: 13, - sql: `CREATE TABLE IF NOT EXISTS catalog_sync_snapshot ( + // Pre-release builds used v10 for catalog synchronization, while the + // released v10 owns turn delegation. Recreate both tables so either + // schema converges after the workspace-transition migrations. + 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), @@ -180,7 +187,7 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ 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'), }, ]; diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 08dc9b9be3a30f..ce1e4f648a011a 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -1215,9 +1215,9 @@ suite('SessionDatabase', () => { }); }); - test('migration v13 converges a pre-release catalog-only v11 database', async () => { - const catalogV11 = await TestableSessionDatabase.open(':memory:', sessionDatabaseMigrations.slice(0, 10)); - await catalogV11.runRaw(`CREATE TABLE catalog_sync_snapshot ( + test('migration v13 converges a pre-release 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), @@ -1226,9 +1226,9 @@ suite('SessionDatabase', () => { pending_hash TEXT, pending_payload TEXT )`); - await catalogV11.runRaw('PRAGMA user_version = 11'); - await catalogV11.setMetadataValuesAndCatalogSyncSnapshot({}, snapshot(1)); - const rawDatabase = await catalogV11.ejectDb(); + 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)); From 3392bf9d61d3b5c848eb09bfd8d7d7de5e404713 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 5 Sep 2026 11:51:11 +0200 Subject: [PATCH 28/30] agentHost: avoid claiming untitled legacy sessions Keep unadopted legacy sessions out of Agent Host title generation so list-only migration cannot create local session storage and prematurely transfer provider ownership. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/node/agentService.ts | 5 ++++- src/vs/platform/agentHost/test/node/agentService.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 28d513892cd5b6..0e52c7c071760a 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -945,6 +945,9 @@ export class AgentService extends Disposable implements IAgentService { /** Titles one external session from the first user prompt of its default chat. */ private async _generateExternalSessionTitle(metadata: IAgentSessionMetadata): Promise { + if (readSessionEhcliAdoptable(metadata._meta)) { + return; + } const session = metadata.session; const agent = this._providerService.getProviderForSession(session); if (!agent) { @@ -2366,7 +2369,7 @@ export class AgentService extends Disposable implements IAgentService { const metadata = { ...imported.value, _meta: withSessionExternal(imported.value._meta, imported.external) }; if (imported.external) { importedExternal = true; - if (!metadata.summary) { + if (!metadata.summary && !readSessionEhcliAdoptable(metadata._meta)) { untitledExternal.push(metadata); } } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 5dadf68903c40a..fc893fb657f93c 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -7323,7 +7323,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('restores side effects for newly imported external and host-created sessions without surfacing adoptable sessions', async () => { + test('restores import side effects without titling or surfacing adoptable sessions', async () => { const database = new TransientRegistryWriteDatabase(); const perSession = createPerSessionDataService(); const external = AgentSession.uri('copilot', 'untitled-external-import'); @@ -7336,7 +7336,7 @@ suite('AgentService (node dispatcher)', () => { agent.catalog = [ { ...metadata(external), summary: undefined }, metadata(hostCreated), - metadata(adoptable, withSessionEhcliAdoptable(undefined)), + { ...metadata(adoptable, withSessionEhcliAdoptable(undefined)), summary: undefined }, ]; const scheduledTitles: string[] = []; let reconciliationCalls = 0; From 075d468556b064433d8a8f9e3ae9396c7a68f16e Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 6 Sep 2026 13:36:37 +0200 Subject: [PATCH 29/30] agentHost: coalesce background catalog writes Keep one active synchronization and one merged trailing projection per session so summary bursts do not build an unbounded SQLite write backlog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentService.ts | 69 ++++++++++++------- .../agentHost/test/node/agentService.test.ts | 53 ++++++++++++++ 2 files changed, 99 insertions(+), 23 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 0e52c7c071760a..4bccad1080b197 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -127,6 +127,12 @@ interface IRecentLocalSessionUpdate { readonly modifiedTime: number; } +interface IBackgroundCatalogStateWrite { + promise: Promise; + trailing: boolean; + trailingOverrides: Record; +} + interface ISessionListComputation { epoch: number; readonly promise: Promise; @@ -475,7 +481,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _catalogListRepair = this._register(new MutableDisposable()); private readonly _catalogSyncSuppressedSessions = new Set(); private readonly _deferredCatalogMetadataOverrides = new Map>(); - private readonly _backgroundCatalogStateWrites = 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(); @@ -904,7 +910,7 @@ export class AgentService extends Disposable implements IAgentService { async whenCatalogReconciliationIdle(): Promise { await this._catalogReconciliationService.whenIdle(); while (this._backgroundCatalogStateWrites.size > 0) { - await Promise.allSettled([...this._backgroundCatalogStateWrites.values()].flatMap(writes => [...writes])); + await Promise.allSettled([...this._backgroundCatalogStateWrites.values()].map(write => write.promise)); } } @@ -1727,23 +1733,43 @@ export class AgentService extends Disposable implements IAgentService { || !this._stateManager.getSessionState(sessionKey)) { return; } - let writes = this._backgroundCatalogStateWrites.get(sessionKey); - if (!writes) { - writes = new Set(); - this._backgroundCatalogStateWrites.set(sessionKey, writes); + const pending = this._backgroundCatalogStateWrites.get(sessionKey); + if (pending) { + pending.trailing = true; + Object.assign(pending.trailingOverrides, metadataOverrides); + return; } - const write = this._persistListVisibleSessionStateNow(session, metadataOverrides); - writes.add(write); - const clear = () => { - writes.delete(write); - if (writes.size === 0) { + const write: IBackgroundCatalogStateWrite = { + promise: Promise.resolve(), + trailing: false, + trailingOverrides: {}, + }; + this._backgroundCatalogStateWrites.set(sessionKey, write); + write.promise = this._drainBackgroundCatalogStateWrites(session, metadataOverrides, write); + } + + private async _drainBackgroundCatalogStateWrites(session: URI, initialOverrides: Readonly>, write: IBackgroundCatalogStateWrite): Promise { + const sessionKey = session.toString(); + let metadataOverrides = initialOverrides; + try { + while (true) { + try { + await this._persistListVisibleSessionStateNow(session, metadataOverrides); + } catch (error) { + this._logService.warn(`[AgentService] Failed to persist list-visible session state for ${sessionKey}`, error); + } + if (!write.trailing) { + return; + } + metadataOverrides = write.trailingOverrides; + write.trailingOverrides = {}; + write.trailing = false; + } + } finally { + if (this._backgroundCatalogStateWrites.get(sessionKey) === write) { 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 { @@ -1771,10 +1797,7 @@ export class AgentService extends Disposable implements IAgentService { 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._whenBackgroundCatalogStateWritesIdle(sessionKey); await this._persistListVisibleSessionStateNow(session, { ...deferredOverrides, ...metadataOverrides }, chatsOverride); } @@ -4892,11 +4915,11 @@ export class AgentService extends Disposable implements IAgentService { private async _whenBackgroundCatalogStateWritesIdle(sessionKey: string): Promise { while (true) { - const writes = this._backgroundCatalogStateWrites.get(sessionKey); - if (!writes || writes.size === 0) { + const write = this._backgroundCatalogStateWrites.get(sessionKey); + if (!write) { return; } - await Promise.allSettled([...writes]); + await Promise.allSettled([write.promise]); } } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index fc893fb657f93c..e2eaecb944bcdc 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3866,6 +3866,59 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('coalesces bursty catalog synchronization into one trailing write', async () => { + class RecordingCatalogDatabase extends TestSessionDatabase { + readonly catalogWrites: Readonly>[] = []; + + override async setMetadataValuesAndCatalogSyncSnapshot(values: Readonly>, snapshot: ISessionCatalogSyncPendingSnapshot): Promise { + this.catalogWrites.push({ ...values }); + return super.setMetadataValuesAndCatalogSyncSnapshot(values, snapshot); + } + } + + const database = new RecordingCatalogDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(database), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + registerTestAgentProvider(svc, copilotAgent); + const session = await svc.createSession({ provider: 'copilot' }); + await svc.whenCatalogReconciliationIdle(); + const initialWriteCount = database.catalogWrites.length; + const internals = svc as unknown as { + _catalogSyncService: { + 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]: 'First title' }); + internals._queueCatalogSync(session, { [SESSION_CUSTOM_TITLE_KEY]: 'Latest title' }); + internals._queueCatalogSync(session, { [SESSION_CUSTOM_TITLE_SOURCE_KEY]: 'user' }); + internals._queueCatalogSync(session, { [SESSION_ARTIFACTS_KEY]: '[]' }); + internals._queueCatalogSync(session, {}); + releaseBlocker.complete(); + await blocker; + await svc.whenCatalogReconciliationIdle(); + + const writes = database.catalogWrites.slice(initialWriteCount); + assert.deepStrictEqual({ + writeCount: writes.length, + titles: writes.map(write => write[SESSION_CUSTOM_TITLE_KEY]), + trailingTitleSource: writes.at(-1)?.[SESSION_CUSTOM_TITLE_SOURCE_KEY], + trailingArtifacts: writes.at(-1)?.[SESSION_ARTIFACTS_KEY], + }, { + writeCount: 2, + titles: ['First title', 'Latest title'], + trailingTitleSource: 'user', + trailingArtifacts: '[]', + }); + }); + test('is a no-op for unknown sessions', async () => { registerTestAgentProvider(service, copilotAgent); const unknownSession = URI.from({ scheme: 'unknown', path: '/nope' }); From 9c0669cb71f4516b788f6943b870ab8c602af505 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sun, 6 Sep 2026 19:10:17 +0200 Subject: [PATCH 30/30] agentHost: fix catalog ESLint warnings Use type-safe property access in the payload decoder and the database sequencing test so full-repository ESLint passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/node/agentHostCatalogProjection.ts | 2 +- .../platform/agentHost/test/node/agentHostDatabase.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index 29b983984cc896..4f25f874f20127 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -411,7 +411,7 @@ export function decodeAgentHostCatalogPayload(payload: string): AgentHostCatalog if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { return invalidPayload('Expected a payload object.'); } - const payloadVersion = (parsed as Record)['payloadVersion']; + const payloadVersion = (parsed as Record).payloadVersion; if (typeof payloadVersion !== 'number' || !Number.isSafeInteger(payloadVersion) || payloadVersion < 0) { return invalidPayload('Expected a non-negative safe integer payloadVersion.'); } diff --git a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts index d8036ec1322e5d..7791b119a158b4 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDatabase.test.ts @@ -338,7 +338,10 @@ suite('AgentHostDatabase sessions_v2', () => { }, { checkTombstone: false }); const release = new DeferredPromise(); const queued = new DeferredPromise(); - const blocker = sequencedDatabase['_transactionSequencer'].queue(async () => { + const transactionSequencer = (sequencedDatabase as unknown as { + readonly _transactionSequencer: { queue(task: () => Promise): Promise }; + })._transactionSequencer; + const blocker = transactionSequencer.queue(async () => { await queued.complete(); await release.p; });