From 55a44c4b4c3c964c9497b16a6b5eafbdd49e7e9b Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 7 Jun 2026 14:26:25 -0700 Subject: [PATCH] agentHost: restore missing Copilot worktrees on resume Recreate persisted worktrees before normal Copilot session resume, but keep archived history lookup lazy by using the persisted repository root with an uncached temporary session. Avoid replacing missing-directory resume failures with empty sessions, and cover archived lookup races/subagent lifetime. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 149 ++++++++++-- .../node/copilot/copilotSessionLauncher.ts | 2 +- .../agentHost/test/node/copilotAgent.test.ts | 228 ++++++++++++++++++ 3 files changed, 360 insertions(+), 19 deletions(-) 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);