diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 23d008d0f91a3..e75d5bf5f7645 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -12,7 +12,7 @@ import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../base/common/map.js'; +import { ResourceMap, ResourceSet } from '../../../../base/common/map.js'; import { FileAccess } from '../../../../base/common/network.js'; import { equals } from '../../../../base/common/objects.js'; import { observableValue } from '../../../../base/common/observable.js'; @@ -56,6 +56,11 @@ interface ICreatedWorktree { readonly worktree: URI; } +interface IResumeSessionOptions { + readonly recreateMissingWorktree: boolean; + readonly cache: boolean; +} + export type ICopilotPluginInfo = IParsedPlugin & { readonly pluginDir?: URI }; /** @@ -268,6 +273,7 @@ export class CopilotAgent extends Disposable implements IAgent { readonly onDidCustomizationsChange: Event; /** Per-session active client state for tools + plugin snapshot tracking. */ private readonly _activeClients = new ResourceMap(); + private readonly _archivedSessions = new ResourceSet(); constructor( @ILogService private readonly _logService: ILogService, @@ -1210,14 +1216,18 @@ export class CopilotAgent extends Disposable implements IAgent { rootSession = parentParsed.parentSession; } const rootSessionId = AgentSession.id(rootSession); - const parentEntry = this._sessions.get(rootSessionId) ?? await this._resumeSession(rootSessionId).catch(err => { + const parentEntry = await this._getSessionForMessageLookup(rootSession).catch(err => { this._logService.warn(`[Copilot:${rootSessionId}] Failed to resume root for subagent restore`, err); return undefined; }); if (!parentEntry) { return []; } - return parentEntry.getSubagentMessages(subagentInfo.toolCallId); + try { + return await parentEntry.object.getSubagentMessages(subagentInfo.toolCallId); + } finally { + parentEntry.dispose(); + } } const sessionId = AgentSession.id(session); @@ -1225,16 +1235,20 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._provisionalSessions.has(sessionId)) { return []; } - const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => { + const entry = await this._getSessionForMessageLookup(session).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for message lookup`, err); return undefined; }); if (!entry) { return []; } - const rawTurns = await entry.getMessages(); + let rawTurns: readonly Turn[]; + try { + rawTurns = await entry.object.getMessages(); + } finally { + entry.dispose(); + } - // If a worktree was created for this session at create-time, prepend // If a worktree was created for this session at create-time, prepend // the announcement to the first turn so it appears at the top of the // first response when the session is reopened. The live path @@ -1251,6 +1265,41 @@ export class CopilotAgent extends Disposable implements IAgent { return prependAnnouncementToFirstTurn(rawTurns, buildWorktreeAnnouncementText(worktreeMeta.branchName)); } + private async _getSessionForMessageLookup(session: URI): Promise<{ readonly object: CopilotAgentSession; dispose(): void } | undefined> { + const sessionId = AgentSession.id(session); + const cached = this._sessions.get(sessionId); + if (cached) { + return { object: cached, dispose: () => { } }; + } + + const isArchived = await this._isSessionArchived(session); + const hadActiveClient = this._activeClients.has(session); + let entry: CopilotAgentSession; + try { + entry = await this._resumeSession(sessionId, { recreateMissingWorktree: !isArchived, cache: !isArchived }); + } catch (error) { + if (isArchived && !hadActiveClient) { + this._activeClients.get(session)?.dispose(); + this._activeClients.delete(session); + } + throw error; + } + if (!isArchived) { + return { object: entry, dispose: () => { } }; + } + + return { + object: entry, + dispose: () => { + entry.dispose(); + if (!hadActiveClient) { + this._activeClients.get(session)?.dispose(); + this._activeClients.delete(session); + } + } + }; + } + async disposeSession(session: URI): Promise { const sessionId = AgentSession.id(session); await this._sessionSequencer.queue(sessionId, async () => { @@ -1261,10 +1310,15 @@ export class CopilotAgent extends Disposable implements IAgent { async onArchivedChanged(session: URI, isArchived: boolean): Promise { const sessionId = AgentSession.id(session); await this._sessionSequencer.queue(sessionId, async () => { + if (isArchived) { + this._archivedSessions.add(session); + } else { + this._archivedSessions.delete(session); + } if (isArchived) { await this._cleanupWorktreeOnArchive(session, sessionId); } else { - await this._recreateWorktreeOnUnarchive(session, sessionId); + await this._recreateWorktreeIfMissing(session, sessionId, 'unarchive'); } }); } @@ -1309,7 +1363,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _recreateWorktreeOnUnarchive(session: URI, sessionId: string): Promise { + private async _recreateWorktreeIfMissing(session: URI, sessionId: string, reason: 'resume' | 'unarchive'): Promise { const meta = await this._readWorktreeMetadata(session).catch(() => undefined); if (!meta?.worktreePath || !meta.repositoryRoot) { return; @@ -1321,14 +1375,14 @@ export class CopilotAgent extends Disposable implements IAgent { await fs.access(worktreePath.fsPath); return; } catch { - // expected when the worktree was cleaned up on archive + // expected when the worktree was cleaned up on archive or externally removed } // Skip if the branch is missing — we have no commit to attach the // recreated worktree to. const branchPresent = await this._gitService.branchExists(repositoryRoot, branchName).catch(() => false); if (!branchPresent) { - this._logService.info(`[Copilot:${sessionId}] Skipping worktree recreation: branch '${branchName}' is missing`); + this._logService.info(`[Copilot:${sessionId}] Skipping worktree recreation on ${reason}: branch '${branchName}' is missing`); return; } @@ -1336,9 +1390,9 @@ export class CopilotAgent extends Disposable implements IAgent { await fs.mkdir(URI.joinPath(worktreePath, '..').fsPath, { recursive: true }); await this._gitService.addExistingWorktree(repositoryRoot, worktreePath, branchName); this._createdWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath }); - this._logService.info(`[Copilot:${sessionId}] Recreated worktree '${worktreePath.fsPath}' on unarchive`); + this._logService.info(`[Copilot:${sessionId}] Recreated worktree '${worktreePath.fsPath}' on ${reason}`); } catch (error) { - this._logService.warn(`[Copilot:${sessionId}] Failed to recreate worktree '${worktreePath.fsPath}' on unarchive: ${error instanceof Error ? error.message : String(error)}`); + this._logService.warn(`[Copilot:${sessionId}] Failed to recreate worktree '${worktreePath.fsPath}' on ${reason}: ${error instanceof Error ? error.message : String(error)}`); } } @@ -1545,12 +1599,15 @@ export class CopilotAgent extends Disposable implements IAgent { await this._removeCreatedWorktree(sessionId); } - protected _resumeSession(sessionId: string): Promise { + protected _resumeSession(sessionId: string, options: IResumeSessionOptions = { recreateMissingWorktree: true, cache: true }): Promise { + if (!options.cache) { + return this._doResumeSession(sessionId, options); + } const existing = this._resumingSessions.get(sessionId); if (existing) { return existing; } - const promise = this._doResumeSession(sessionId); + const promise = this._doResumeSession(sessionId, options); this._resumingSessions.set(sessionId, promise); const cleanup = () => { if (this._resumingSessions.get(sessionId) === promise) { @@ -1561,7 +1618,7 @@ export class CopilotAgent extends Disposable implements IAgent { return promise; } - private async _doResumeSession(sessionId: string): Promise { + private async _doResumeSession(sessionId: string, options: IResumeSessionOptions): Promise { this._logService.info(`[Copilot:${sessionId}] _resumeSession called — session not in memory, resuming...`); const client = await this._ensureClient(); @@ -1582,13 +1639,14 @@ export class CopilotAgent extends Disposable implements IAgent { throw new Error(`workingDirectory is required to resume Copilot session '${sessionId}'`); } - const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); + const resumeWorkingDirectory = await this._resolveResumeWorkingDirectory(sessionUri, sessionId, workingDirectory, options.recreateMissingWorktree); + const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, resumeWorkingDirectory); const resolvedAgentName = storedMetadata.agent ? await this._resolveAgentName(sessionUri, snapshot, storedMetadata.agent) : undefined; const launchPlan: CopilotSessionLaunchPlan = { kind: 'resume', client, sessionId, - workingDirectory, + workingDirectory: resumeWorkingDirectory, resolvedAgentName, snapshot, shellManager, @@ -1605,11 +1663,66 @@ export class CopilotAgent extends Disposable implements IAgent { agentSession.dispose(); throw err; } - this._registerInitializedSession(sessionId, agentSession); + if (options.cache) { + this._registerInitializedSession(sessionId, agentSession); + } return agentSession; } + private async _resolveResumeWorkingDirectory(session: URI, sessionId: string, workingDirectory: URI, recreateMissingWorktree: boolean): Promise { + if (recreateMissingWorktree) { + await this._recreateWorktreeIfMissing(session, sessionId, 'resume'); + return workingDirectory; + } + + const worktreeMeta = await this._readWorktreeMetadata(session).catch(() => undefined); + if (!worktreeMeta?.worktreePath || worktreeMeta.worktreePath.toString() !== workingDirectory.toString()) { + return workingDirectory; + } + + if (await this._isAccessibleDirectory(workingDirectory)) { + return workingDirectory; + } + + const repositoryRoot = worktreeMeta.repositoryRoot; + if (repositoryRoot && await this._isAccessibleDirectory(repositoryRoot)) { + this._logService.info(`[Copilot:${sessionId}] Resuming archived session with repository root '${repositoryRoot.fsPath}' because worktree '${workingDirectory.fsPath}' is missing`); + return repositoryRoot; + } + + return workingDirectory; + } + + private async _isAccessibleDirectory(directory: URI | undefined): Promise { + if (!directory) { + return false; + } + try { + const stat = await fs.stat(directory.fsPath); + return stat.isDirectory(); + } catch { + return false; + } + } + + private async _isSessionArchived(session: URI): Promise { + if (this._archivedSessions.has(session)) { + return true; + } + + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return false; + } + try { + const metadata = await ref.object.getMetadataObject({ isArchived: true, isDone: true }); + return metadata.isArchived === 'true' || (metadata.isArchived === undefined && metadata.isDone === 'true'); + } finally { + ref.dispose(); + } + } + private async _getGitInfo(workingDirectory: URI): Promise<{ currentBranch: string; defaultBranch: string } | undefined> { if (!await this._gitService.isInsideWorkTree(workingDirectory)) { return undefined; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 043c025c8f6b1..782145561fa17 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -152,7 +152,7 @@ function shouldCreateEmptySessionAfterResumeError(err: unknown): boolean { } const message = getErrorMessage(err); - return !/\b(corrupt|corrupted|invalid|validation|schema|must be|parse|malformed|unexpected token)\b/i.test(message); + return !/\b(corrupt|corrupted|invalid|validation|schema|must be|parse|malformed|unexpected token|directory does not exist|cannot be accessed)\b/i.test(message); } export function getCopilotReasoningEffort(model: ModelSelection | undefined): SessionConfig['reasoningEffort'] { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 6bb478dbb363d..f9e696ddc9f5b 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -277,6 +277,10 @@ class ResumePathCopilotAgent extends CopilotAgent { protected override _createCopilotClient(): CopilotClient { return this._copilotClient as CopilotClient; } + + resolveWorktreeForTest(config: Parameters[0], sessionId: string, prompt?: string): Promise { + return this._resolveSessionWorkingDirectory(config, sessionId, prompt); + } } class TestableCopilotAgent extends CopilotAgent { @@ -1391,6 +1395,19 @@ suite('CopilotAgent', () => { await disposeAgent(agent); } }); + + test('does not replace a session with an empty session when the working directory is missing', async () => { + const { client, getCreateSessionCalls } = createResumeFailingClient('Request session.resume failed with message: Directory does not exist or cannot be accessed: /missing/worktree'); + const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService: disposables.add(new TestSessionDataService()) }); + const internals = agent as unknown as AgentInternals; + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await assert.rejects(() => internals._resumeSession('s1'), /Directory does not exist/); + assert.strictEqual(getCreateSessionCalls(), 0); + } finally { + await disposeAgent(agent); + } + }); }); suite('worktree announcement', () => { @@ -1626,6 +1643,217 @@ suite('CopilotAgent', () => { } }); + test('resume recreates a missing persisted worktree before calling the SDK', async () => { + const sessionId = 'resume-recreate-worktree-session'; + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-resume'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + const sessionDataService = disposables.add(new TestSessionDataService()); + + const client = new TestCopilotClient([]); + const agent = createTestAgent(disposables, { + sessionDataService, + copilotClient: client, + useRealResumePath: true, + gitService, + }) as ResumePathCopilotAgent; + + const worktree = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main' }, + }, sessionId, 'Resume recreate'); + assert.ok(worktree, 'worktree must be created'); + await fs.rm(worktree.fsPath, { recursive: true, force: true }); + const branchName = gitService.addedWorktrees[0].branchName; + client.getSessionMetadata = async id => sdkSession(id, worktree.fsPath); + let resumeCalled = false; + client.resumeSession = async () => { + resumeCalled = true; + assert.deepStrictEqual( + gitService.addedExistingWorktrees.map(r => ({ worktree: r.worktree.fsPath, branchName: r.branchName })), + [{ worktree: worktree.fsPath, branchName }], + 'worktree must be recreated before SDK resume', + ); + return new MockCopilotSession() as unknown as CopilotSession; + }; + + const internals = agent as unknown as { _resumeSession: (id: string) => Promise }; + try { + await agent.authenticate('https://api.github.com', 'token'); + await internals._resumeSession(sessionId); + assert.strictEqual(resumeCalled, true); + } finally { + await disposeAgent(agent); + } + }); + + test('archived message lookup resumes without recreating a missing persisted worktree', async () => { + const sessionId = 'archived-history-missing-worktree-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-archived-history'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + const agent = createTestAgent(disposables, { + sessionDataService, + copilotClient: client, + useRealResumePath: true, + gitService, + }) as ResumePathCopilotAgent; + + const worktree = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main' }, + }, sessionId, 'Archived history'); + assert.ok(worktree, 'worktree must be created'); + await fs.rm(worktree.fsPath, { recursive: true, force: true }); + const dbRef = sessionDataService.openDatabase(session); + try { + await dbRef.object.setMetadata('isArchived', 'true'); + } finally { + dbRef.dispose(); + } + + client.getSessionMetadata = async id => sdkSession(id, worktree.fsPath); + let resumeWorkingDirectory: string | undefined; + let resumeCalls = 0; + client.resumeSession = async (_id, options) => { + resumeCalls++; + resumeWorkingDirectory = options.workingDirectory; + return new MockCopilotSession() as unknown as CopilotSession; + }; + + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.getSessionMessages(session); + await agent.getSessionMessages(session); + assert.deepStrictEqual({ + resumeCalls, + resumeWorkingDirectory, + recreatedWorktrees: gitService.addedExistingWorktrees, + }, { + resumeCalls: 2, + resumeWorkingDirectory: repositoryRoot.fsPath, + recreatedWorktrees: [], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('archived message lookup uses live archive state when archive metadata is not persisted yet', async () => { + const sessionId = 'archived-history-live-state-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-archived-live-state'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + const agent = createTestAgent(disposables, { + sessionDataService, + copilotClient: client, + useRealResumePath: true, + gitService, + }) as ResumePathCopilotAgent; + + const worktree = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main' }, + }, sessionId, 'Archived live state'); + assert.ok(worktree, 'worktree must be created'); + await fs.rm(worktree.fsPath, { recursive: true, force: true }); + await agent.onArchivedChanged(session, true); + + client.getSessionMetadata = async id => sdkSession(id, worktree.fsPath); + let resumeWorkingDirectory: string | undefined; + client.resumeSession = async (_id, options) => { + resumeWorkingDirectory = options.workingDirectory; + return new MockCopilotSession() as unknown as CopilotSession; + }; + + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.getSessionMessages(session); + assert.deepStrictEqual({ + resumeWorkingDirectory, + recreatedWorktrees: gitService.addedExistingWorktrees, + }, { + resumeWorkingDirectory: repositoryRoot.fsPath, + recreatedWorktrees: [], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('archived subagent message lookup keeps temporary session alive until messages load', async () => { + const sessionId = 'archived-subagent-history-session'; + const session = AgentSession.uri('copilotcli', sessionId); + const subagentSession = URI.parse(buildSubagentSessionUri(session.toString(), 'tc-subagent')); + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-archived-subagent-history'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + const agent = createTestAgent(disposables, { + sessionDataService, + copilotClient: client, + useRealResumePath: true, + gitService, + }) as ResumePathCopilotAgent; + + const worktree = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main' }, + }, sessionId, 'Archived subagent history'); + assert.ok(worktree, 'worktree must be created'); + await fs.rm(worktree.fsPath, { recursive: true, force: true }); + const dbRef = sessionDataService.openDatabase(session); + try { + await dbRef.object.setMetadata('isArchived', 'true'); + } finally { + dbRef.dispose(); + } + + const eventsRequested = new DeferredPromise(); + const releaseEvents = new DeferredPromise[]>(); + let disconnected = false; + class DelayedEventsCopilotSession extends MockCopilotSession { + override async getEvents(): Promise[]> { + eventsRequested.complete(); + return releaseEvents.p; + } + + override async disconnect(): Promise { + disconnected = true; + } + } + + client.getSessionMetadata = async id => sdkSession(id, worktree.fsPath); + client.resumeSession = async () => new DelayedEventsCopilotSession() as unknown as CopilotSession; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const messages = agent.getSessionMessages(subagentSession); + await eventsRequested.p; + assert.strictEqual(disconnected, false); + releaseEvents.complete([]); + await messages; + assert.strictEqual(disconnected, true); + } finally { + await disposeAgent(agent); + } + }); + test('onArchivedChanged skips removal when worktree has uncommitted changes', async () => { const sessionId = 'archive-skip-dirty-session'; const session = AgentSession.uri('copilotcli', sessionId);