From 069707d0b5ef724ddf00ff0d1723ebcd850afeaf Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Wed, 2 Sep 2026 11:04:01 +0200 Subject: [PATCH] Agent Host: Manage idle Dev Container lifecycle Stop Dev Containers when their sessions become idle, reconnect them on demand, and remove containers before archiving mounted worktrees. Coordinate suspension and resume across renderer connections and cover failure and shared-session cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/devContainerAgentHost.ts | 4 + .../node/devContainerAgentHostService.ts | 76 +++++++- .../node/devContainerAgentHostService.test.ts | 67 +++++++ .../common/devContainerAgentHostService.ts | 4 +- .../browser/baseAgentHostSessionsProvider.ts | 14 +- .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 2 + .../browser/devContainerAgentHostService.ts | 126 +++++++++++-- .../remoteAgentHostSessionsProvider.ts | 174 ++++++++++++++++-- ...ontainerAgentHostConnector.contribution.ts | 12 +- .../devContainerAgentHostService.test.ts | 85 +++++++++ .../remoteAgentHostSessionsProvider.test.ts | 165 ++++++++++++++++- 11 files changed, 686 insertions(+), 43 deletions(-) diff --git a/src/vs/platform/agentHost/common/devContainerAgentHost.ts b/src/vs/platform/agentHost/common/devContainerAgentHost.ts index e09e7b6a9bfde..475337a1b296f 100644 --- a/src/vs/platform/agentHost/common/devContainerAgentHost.ts +++ b/src/vs/platform/agentHost/common/devContainerAgentHost.ts @@ -14,6 +14,8 @@ export interface IDevContainerAgentHostConfig { readonly connectionId: string; readonly workspaceFolder: string; readonly name: string; + /** Whether this user-initiated connection may resume a deliberately stopped container. */ + readonly resume?: boolean; } /** Serializable connection metadata returned to the renderer. */ @@ -44,4 +46,6 @@ export interface IDevContainerAgentHostMainService extends IRelayChannel { isDockerAvailable(): Promise; connect(config: IDevContainerAgentHostConfig): Promise; disconnect(connectionId: string): Promise; + stopContainer(workspaceFolder: string): Promise; + removeContainer(workspaceFolder: string): Promise; } diff --git a/src/vs/platform/agentHost/node/devContainerAgentHostService.ts b/src/vs/platform/agentHost/node/devContainerAgentHostService.ts index ba32428542924..68c83d12861e5 100644 --- a/src/vs/platform/agentHost/node/devContainerAgentHostService.ts +++ b/src/vs/platform/agentHost/node/devContainerAgentHostService.ts @@ -12,6 +12,7 @@ import { Emitter } from '../../../base/common/event.js'; import { FileAccess } from '../../../base/common/network.js'; import { join } from '../../../base/common/path.js'; import { findExecutable } from '../../../base/node/processes.js'; +import { SequencerByKey } from '../../../base/common/async.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { vLiteral, vObj, vString } from '../../../base/common/validation.js'; import { localize } from '../../../nls.js'; @@ -98,8 +99,13 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev private readonly _connections = this._register(new DisposableMap()); private readonly _connectionStores = this._register(new DisposableMap()); private readonly _connectionTokenSources = new Map(); + private readonly _connectionWorkspaces = new Map(); + private readonly _containerIds = new Map(); + private readonly _suspendedWorkspaces = new Set(); + private readonly _containerOperations = new SequencerByKey(); private _nativeRequire: NodeJS.Require | undefined; private _shellEnvironment: Promise | undefined; + private _dockerExecutable: Promise | undefined; private _dockerAvailable: Promise | undefined; constructor( @@ -112,7 +118,14 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev super(); } - async connect(config: IDevContainerAgentHostConfig): Promise { + connect(config: IDevContainerAgentHostConfig): Promise { + return this._containerOperations.queue(config.workspaceFolder, () => this._connect(config)); + } + + private async _connect(config: IDevContainerAgentHostConfig): Promise { + if (this._suspendedWorkspaces.has(config.workspaceFolder) && config.resume !== true) { + throw new Error(localize('devContainerAgentHost.containerSuspended', "Dev Container for '{0}' is stopped.", config.workspaceFolder)); + } await this.disconnect(config.connectionId); const store = new DisposableStore(); const tokenSource = store.add(new CancellationTokenSource()); @@ -131,6 +144,9 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev if (!upResult) { throw new Error(localize('devContainerAgentHost.invalidUpResult', "Dev Container CLI returned an invalid result: {0}", up.stdout.trim() || up.stderr.trim())); } + this._containerIds.set(config.workspaceFolder, upResult.containerId); + this._connectionWorkspaces.set(config.connectionId, config.workspaceFolder); + store.add(toDisposable(() => this._connectionWorkspaces.delete(config.connectionId))); const exec = this._createExec(config.connectionId, config.workspaceFolder, tokenSource.token); const [{ stdout: unameS }, { stdout: unameM }, { stdout: libc }] = await Promise.all([ @@ -196,6 +212,9 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev ); this._connections.set(config.connectionId, relay); store.add(toDisposable(() => this._connections.deleteAndDispose(config.connectionId))); + if (config.resume === true) { + this._suspendedWorkspaces.delete(config.workspaceFolder); + } return { connectionId: config.connectionId, @@ -393,12 +412,61 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev } isDockerAvailable(): Promise { - this._dockerAvailable ??= this._resolveShellEnvironment() - .then(environment => findExecutable('docker', undefined, undefined, environment)) - .then(executable => executable !== undefined); + this._dockerAvailable ??= this._resolveDockerExecutable().then(executable => executable !== undefined); return this._dockerAvailable; } + async stopContainer(workspaceFolder: string): Promise { + await this._containerOperations.queue(workspaceFolder, () => this._changeContainerState(workspaceFolder, 'stop')); + } + + async removeContainer(workspaceFolder: string): Promise { + await this._containerOperations.queue(workspaceFolder, () => this._changeContainerState(workspaceFolder, 'rm')); + } + + private async _changeContainerState(workspaceFolder: string, operation: 'stop' | 'rm'): Promise { + const containerId = this._containerIds.get(workspaceFolder); + if (!containerId) { + return; + } + this._suspendedWorkspaces.add(workspaceFolder); + const connectionIds = [...this._connectionWorkspaces] + .filter(([, workspace]) => workspace === workspaceFolder) + .map(([connectionId]) => connectionId); + await Promise.all(connectionIds.map(connectionId => this.disconnect(connectionId))); + const args = operation === 'rm' ? ['rm', '--force', containerId] : ['stop', containerId]; + const result = await this._runDocker(args); + if (result.code !== 0 && !/No such container/i.test(result.stderr)) { + throw new Error(localize('devContainerAgentHost.containerLifecycleFailed', "Docker failed to {0} Dev Container '{1}' (exit {2}): {3}", operation === 'rm' ? 'remove' : 'stop', containerId, result.code, result.stderr.trim())); + } + if (operation === 'rm' || /No such container/i.test(result.stderr)) { + this._containerIds.delete(workspaceFolder); + } + } + + protected async _runDocker(args: readonly string[]): Promise<{ stdout: string; stderr: string; code: number }> { + const executable = await this._resolveDockerExecutable(); + if (!executable) { + throw new Error(localize('devContainerAgentHost.dockerUnavailable', "Docker is not available.")); + } + const environment = await this._resolveShellEnvironment(); + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { env: environment }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', data => stdout += data.toString()); + child.stderr.on('data', data => stderr += data.toString()); + child.once('error', reject); + child.once('close', code => resolve({ stdout, stderr, code: code ?? -1 })); + }); + } + + private _resolveDockerExecutable(): Promise { + this._dockerExecutable ??= this._resolveShellEnvironment() + .then(environment => findExecutable('docker', undefined, undefined, environment)); + return this._dockerExecutable; + } + protected _spawnDevContainer( args: readonly string[], environment: NodeJS.ProcessEnv, diff --git a/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts index 65711f1e35263..50063a484d727 100644 --- a/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts @@ -33,7 +33,9 @@ class TestRelay implements IDevContainerRelay { class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainService { readonly relay = new TestRelay(); readonly execCommands: string[] = []; + readonly dockerCommands: string[][] = []; relayCommand: string | undefined; + failDevContainerUp = false; constructor( private readonly _libc = '', @@ -66,8 +68,16 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ return this._resolveShellEnvironment(); } + protected override _runDocker(args: readonly string[]): Promise<{ stdout: string; stderr: string; code: number }> { + this.dockerCommands.push([...args]); + return Promise.resolve({ stdout: `${args.at(-1)}\n`, stderr: '', code: 0 }); + } + protected override _runDevContainer(connectionId: string, args: readonly string[]): Promise<{ stdout: string; stderr: string; code: number }> { assert.deepStrictEqual(args, ['up', '--workspace-folder', '/workspace']); + if (this.failDevContainerUp) { + return Promise.reject(new Error('devcontainer up failed')); + } this._reportOutput(connectionId, 'Starting Dev Container\n'); return Promise.resolve({ stdout: '[1 ms] Starting...\n{"outcome":"success","containerId":"container-id","remoteWorkspaceFolder":"/workspaces/project"}\n', @@ -194,6 +204,63 @@ suite('Dev Container Agent Host Main Service', () => { }); }); + test('stops and removes the container after disconnecting its relay', async () => { + const service = store.add(new TestDevContainerAgentHostMainService()); + await service.connect({ + connectionId: 'connection', + workspaceFolder: '/workspace', + name: 'Project Dev Container', + }); + + await service.stopContainer('/workspace'); + await assert.rejects(service.connect({ + connectionId: 'automatic-reconnect', + workspaceFolder: '/workspace', + name: 'Project Dev Container', + }), /is stopped/); + await service.connect({ + connectionId: 'explicit-resume', + workspaceFolder: '/workspace', + name: 'Project Dev Container', + resume: true, + }); + await service.removeContainer('/workspace'); + + assert.deepStrictEqual({ + relayDisposed: service.relay.disposed, + dockerCommands: service.dockerCommands, + }, { + relayDisposed: true, + dockerCommands: [ + ['stop', 'container-id'], + ['rm', '--force', 'container-id'], + ], + }); + }); + + test('keeps automatic reconnects suspended when an explicit resume fails', async () => { + const service = store.add(new TestDevContainerAgentHostMainService()); + await service.connect({ + connectionId: 'connection', + workspaceFolder: '/workspace', + name: 'Project Dev Container', + }); + await service.stopContainer('/workspace'); + service.failDevContainerUp = true; + + await assert.rejects(service.connect({ + connectionId: 'failed-resume', + workspaceFolder: '/workspace', + name: 'Project Dev Container', + resume: true, + }), /devcontainer up failed/); + await assert.rejects(service.connect({ + connectionId: 'automatic-reconnect', + workspaceFolder: '/workspace', + name: 'Project Dev Container', + }), /is stopped/); + }); + test('installs the Alpine CLI artifact in a musl container', async () => { const service = store.add(new TestDevContainerAgentHostMainService('musl', true)); await service.connect({ diff --git a/src/vs/sessions/common/devContainerAgentHostService.ts b/src/vs/sessions/common/devContainerAgentHostService.ts index 483bec42cc0ec..6e38991169c2a 100644 --- a/src/vs/sessions/common/devContainerAgentHostService.ts +++ b/src/vs/sessions/common/devContainerAgentHostService.ts @@ -33,7 +33,9 @@ export interface IDevContainerAgentHostConnection { export interface IDevContainerAgentHostConnector { /** Whether the workspace has a supported configuration and Docker is available. */ isAvailable(workspaceUri: URI): Promise; - createConnection(workspaceUri: URI, address: string, token: CancellationToken): Promise; + createConnection(workspaceUri: URI, address: string, token: CancellationToken, options?: { readonly resume: boolean }): Promise; + stopContainer?(workspaceUri: URI): Promise; + removeContainer?(workspaceUri: URI): Promise; } /** Sessions provider and workspace selected after connecting a Dev Container. */ diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index fe45aa93ea9ac..17400e495b7f7 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -2678,7 +2678,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return this._newSessions.get(sessionId); } - private _getBackendSessionUri(sessionId: string): URI | undefined { + protected _getBackendSessionUri(sessionId: string): URI | undefined { const rawId = this._rawIdFromChatId(sessionId); if (!rawId) { return undefined; @@ -4294,9 +4294,19 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!cached || !rawId || !connection) { return false; } + this._setSessionArchivedLocally(sessionId, isArchived); + connection.dispatch(cached.backendUri.toString(), { type: ActionType.SessionIsArchivedChanged as const, isArchived }); + return true; + } + + protected _setSessionArchivedLocally(sessionId: string, isArchived: boolean): boolean { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (!cached) { + return false; + } cached.isArchived.set(isArchived, undefined); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); - connection.dispatch(cached.backendUri.toString(), { type: ActionType.SessionIsArchivedChanged as const, isArchived }); return true; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index 5b7efc63bf0c9..38d3618bbcd1f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -86,6 +86,8 @@ The service stages a runtime-only `DevContainer` entry and asks the remote Agent When both worktree isolation and Dev Container execution are selected, the local Agent Host creates the worktree before the container starts. The connector opens the Dev Container on that host worktree, and the container-backed session uses folder isolation so it does not create a second worktree inside the container. The remote session stores only an opaque worktree handle in its metadata; authoritative host paths stay in a local detached-worktree record. Archive, unarchive, and delete resolve that handle through the local Agent Host so cleanup and recreation match ordinary local worktree sessions without retaining a hidden local session. Successful remote listings reconcile their active handles with old local records; cleanup removes only clean worktrees and preserves dirty work. +The dynamic provider remains registered after its sessions become idle, but withdraws its runtime connection and stops the Dev Container once no session is actively working or waiting for input. A later send restarts the container and reconnects the same provider on demand. Archiving first records the remote archived state, then removes the container before removing its mounted worktree. Unarchiving recreates the worktree before starting a replacement container and returns the container to the stopped state when the restored session is still idle. + ## Change policy Update this specification only when connection/provider ownership, routing identity, or the shared Agent Host lifecycle boundary changes. Do not append transport algorithms, telemetry schemas, retry narratives, or incident history. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts index 47b9818240e50..91ed69429aa36 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devContainerAgentHostService.ts @@ -5,7 +5,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../base/common/errors.js'; -import { raceCancellationError, raceTimeout } from '../../../../../base/common/async.js'; +import { raceCancellationError, raceTimeout, SequencerByKey } from '../../../../../base/common/async.js'; import { Event } from '../../../../../base/common/event.js'; import { getComparisonKey } from '../../../../../base/common/resources.js'; import { StringSHA1 } from '../../../../../base/common/hash.js'; @@ -27,6 +27,9 @@ interface IActiveDevContainerAgentHost { readonly address: string; readonly provider: RemoteAgentHostSessionsProvider; readonly target: Omit; + readonly connector: IDevContainerAgentHostConnector; + readonly workspaceUri: URI; + state: 'running' | 'stopping' | 'stopped' | 'removing' | 'removed' | 'connecting'; references: number; } @@ -90,7 +93,7 @@ class DevContainerConnectionFactory extends Disposable implements IRemoteAgentHo this._updateEntries(); } - async createConnection(entry: IRemoteAgentHostEntry, _options: IRemoteAgentHostConnectOptions): Promise { + async createConnection(entry: IRemoteAgentHostEntry, options: IRemoteAgentHostConnectOptions): Promise { if (entry.connection.type !== RemoteAgentHostEntryType.DevContainer) { throw new Error(`Dev Container factory cannot create a ${entry.connection.type} connection.`); } @@ -103,6 +106,7 @@ class DevContainerConnectionFactory extends Disposable implements IRemoteAgentHo staged.workspaceUri, entry.connection.address, CancellationToken.None, + { resume: options.userInitiated }, ); try { const authority = agentHostAuthority(entry.connection.address); @@ -143,6 +147,8 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont private readonly _activeConnections = new Map(); private readonly _pendingConnections = new Map(); private readonly _connectionFactory: DevContainerConnectionFactory; + private readonly _lifecycleOperations = new SequencerByKey(); + private readonly _lifecycleTokenSource = this._register(new CancellationTokenSource()); private _connector: IDevContainerAgentHostConnector | undefined; constructor( @@ -175,8 +181,8 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont connect(workspaceUri: URI, token: CancellationToken): Promise { const key = getComparisonKey(workspaceUri); const active = this._activeConnections.get(key); - if (active && this._isConnectedOrReconnecting(active.address)) { - return Promise.resolve(this._acquireConnection(key, active)); + if (active) { + return raceCancellationError(this._ensureActiveConnection(key, active), token).then(() => this._acquireConnection(key, active)); } const pending = this._pendingConnections.get(key); if (pending) { @@ -187,7 +193,7 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont } const tokenSource = new CancellationTokenSource(token); - const promise = this._replaceConnectionAndConnect(this._connector, workspaceUri, key, active, tokenSource.token); + const promise = this._connect(this._connector, workspaceUri, key, tokenSource.token); const pendingConnection = { promise, tokenSource }; this._pendingConnections.set(key, pendingConnection); void promise.then( @@ -204,24 +210,11 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont pending.tokenSource.dispose(); } - private async _replaceConnectionAndConnect( - connector: IDevContainerAgentHostConnector, - workspaceUri: URI, - key: string, - active: IActiveDevContainerAgentHost | undefined, - token: CancellationToken, - ): Promise { - if (active) { - await this._removeActiveConnection(key, active); - } - return this._connect(connector, workspaceUri, key, token); - } - private async _connect(connector: IDevContainerAgentHostConnector, workspaceUri: URI, key: string, token: CancellationToken): Promise { if (token.isCancellationRequested) { throw new CancellationError(); } - const connected = await connector.createConnection(workspaceUri, devContainerAddress(workspaceUri), token); + const connected = await connector.createConnection(workspaceUri, devContainerAddress(workspaceUri), token, { resume: true }); if (token.isCancellationRequested) { connected.transportDisposable?.dispose(); throw new CancellationError(); @@ -230,10 +223,16 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont const providerStore = new DisposableStore(); let stagedAddress: string | undefined; try { + const devContainerLifecycle = connector.stopContainer && connector.removeContainer ? { + connect: () => this._reconnectContainer(key), + stop: () => this._stopContainer(key), + remove: () => this._removeContainer(key), + } : undefined; const provider = providerStore.add(this._createProvider({ address: connected.address, name: connected.name, devContainerWorktreeScope: key, + ...(devContainerLifecycle ? { devContainerLifecycle } : {}), omitHostFromWorkspaceLabel: true, })); providerStore.add(this._sessionsProvidersService.registerProvider(provider)); @@ -255,7 +254,7 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont await this._waitForSessionTypes(provider, token); const target = { providerId: provider.id, workspaceUri: connected.workspaceUri }; - const active = { address, provider, target, references: 0 }; + const active: IActiveDevContainerAgentHost = { address, provider, target, connector, workspaceUri, state: 'running', references: 0 }; providerStore.add(toDisposable(() => this._activeConnections.delete(key))); this._providerStores.set(key, providerStore); this._activeConnections.set(key, active); @@ -333,10 +332,92 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont } private async _removeActiveConnection(key: string, active: IActiveDevContainerAgentHost): Promise { + this._connectionFactory.unstageConnection(active.address); await this._remoteAgentHostService.removeRemoteAgentHost(active.address); this._providerStores.deleteAndDispose(key); } + private _reconnectContainer(key: string): Promise { + const active = this._activeConnections.get(key); + return active ? this._ensureActiveConnection(key, active) : Promise.reject(new Error(`Unknown Dev Container connection '${key}'.`)); + } + + private _stopContainer(key: string): Promise { + return this._lifecycleOperations.queue(key, async () => { + const active = this._activeConnections.get(key); + if (!active || active.state === 'stopped' || active.state === 'removed') { + return; + } + active.state = 'stopping'; + await this._disconnectActiveTransport(active); + try { + await active.connector.stopContainer!(active.workspaceUri); + active.state = 'stopped'; + } catch (error) { + await this._connectActive(active); + throw error; + } + }); + } + + private _removeContainer(key: string): Promise { + return this._lifecycleOperations.queue(key, async () => { + const active = this._activeConnections.get(key); + if (!active || active.state === 'removed') { + return; + } + active.state = 'removing'; + await this._disconnectActiveTransport(active); + try { + await active.connector.removeContainer!(active.workspaceUri); + active.state = 'removed'; + } catch (error) { + await this._connectActive(active); + throw error; + } + }); + } + + private _ensureActiveConnection(key: string, active: IActiveDevContainerAgentHost): Promise { + return this._lifecycleOperations.queue(key, async () => { + if (this._isConnectedOrReconnecting(active.address)) { + active.state = 'running'; + return; + } + await this._connectActive(active); + }); + } + + private async _connectActive(active: IActiveDevContainerAgentHost): Promise { + const previousState = active.state; + active.state = 'connecting'; + try { + const connected = await active.connector.createConnection(active.workspaceUri, active.address, this._lifecycleTokenSource.token, { resume: true }); + this._connectionFactory.stageConnection(active.connector, active.workspaceUri, connected); + this._remoteAgentHostService.reconnect(active.address, true); + const connectionInfo = await this._remoteAgentHostService.waitForConnection(active.address); + const connection = this._remoteAgentHostService.getConnection(connectionInfo.address); + if (!connection) { + throw new Error(localize('devContainerAgentHost.connectionUnavailable', "Dev Container Agent Host connection was not available after connecting.")); + } + active.provider.setConnection(connection, connected.defaultDirectory ?? connectionInfo.defaultDirectory); + active.provider.setConnectionStatus(connectionInfo.status); + active.state = 'running'; + } catch (error) { + this._connectionFactory.unstageConnection(active.address); + await this._remoteAgentHostService.removeRemoteAgentHost(active.address); + active.state = previousState; + throw error; + } + } + + private async _disconnectActiveTransport(active: IActiveDevContainerAgentHost): Promise { + this._connectionFactory.unstageConnection(active.address); + active.provider.clearConnection(); + active.provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); + await this._remoteAgentHostService.removeRemoteAgentHost(active.address); + } + private _isConnectedOrReconnecting(address: string): boolean { return this._remoteAgentHostService.connections.some(connection => connection.address === address @@ -348,6 +429,10 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont for (const [key, active] of this._activeConnections) { const connectionInfo = this._remoteAgentHostService.connections.find(connection => connection.address === active.address); if (!connectionInfo) { + if (active.state !== 'running') { + active.provider.setConnectionStatus(RemoteAgentHostConnectionStatus.disconnected); + continue; + } this._providerStores.deleteAndDispose(key); continue; } @@ -362,6 +447,7 @@ export class DevContainerAgentHostService extends Disposable implements IDevCont } override dispose(): void { + this._lifecycleTokenSource.cancel(); for (const pending of this._pendingConnections.values()) { pending.tokenSource.cancel(); pending.tokenSource.dispose(); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 086040dca14a5..a131c9b2721e5 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; @@ -20,7 +21,8 @@ import { AgentSession, type IAgentSessionMetadata } from '../../../../../platfor import { IAgentHostService, type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { IRemoteAgentHostService, removeWebSocketRemoteAgentHostEntry, RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; -import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { StateComponents, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; @@ -37,12 +39,12 @@ import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/co import { ResourceLabelHomeStore } from '../../../../../workbench/services/label/common/resourceLabelHomeStore.js'; import { IAgentHostConnectProgress, IAgentHostGroup } from '../../../../common/agentHostSessionsProvider.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; -import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; +import { IGitHubInfo, IChat, isActiveSessionStatus, ISession, SessionStatus, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { ReconnectableAgentHostAutomationStore } from '../../agentHost/browser/reconnectableAgentHostAutomationStore.js'; -import type { ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; +import type { ISendRequestOptions, ISessionsProviderAutomations } from '../../../../services/sessions/common/sessionsProvider.js'; import { AutomationStore } from '../../../automations/browser/automationService.js'; import { providerAutomationStorageKey } from '../../../automations/common/automationStorageService.js'; import { remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; @@ -50,6 +52,7 @@ import { readAgentDevContainerWorktreeMetadata } from '../../../../../platform/a /** Storage key prefix for cached session summaries, per remote address. */ const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.v2.'; +const DEV_CONTAINER_ARCHIVE_CONFIRMATION_TIMEOUT_MS = 5000; // TODO@sandy081 Remove this legacy cache-key cleanup after 2026-10-14. const CACHED_SESSIONS_STORAGE_PREFIX_LEGACY = 'remoteAgentHost.cachedSessions.'; @@ -96,6 +99,12 @@ export interface IRemoteAgentHostSessionsProviderConfig { */ readonly hostGroup?: IAgentHostGroup; readonly devContainerWorktreeScope?: string; + /** Controls the stopped/removed container while this runtime provider remains registered. */ + readonly devContainerLifecycle?: { + connect(): Promise; + stop(): Promise; + remove(): Promise; + }; } /** @@ -182,6 +191,8 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _workspaceTypeIcon: ThemeIcon | undefined; private readonly _defaultChangesetKind: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; private readonly _devContainerWorktreeScope: string | undefined; + private readonly _devContainerLifecycle: IRemoteAgentHostSessionsProviderConfig['devContainerLifecycle']; + private readonly _lastSessionStatuses = new Map(); /** Storage key used for persisting {@link _sessionCache} snapshots. */ private readonly _storageKey: string; /** @@ -227,10 +238,14 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid this._workspaceTypeIcon = config.workspaceTypeIcon; this._defaultChangesetKind = config.defaultChangesetKind; this._devContainerWorktreeScope = config.devContainerWorktreeScope; + this._devContainerLifecycle = config.devContainerLifecycle; this.onDidReportConnectProgress = config.onDidReportConnectProgress; this.canConnectOnDemand = !!config.connectOnDemand; this._register(this._onDidChangeSessionsImmediately(() => this.updateResourceLabelHomes())); this._register(this._onDidChangeDraftSessions.event(() => this.updateResourceLabelHomes())); + if (this._devContainerLifecycle) { + this._register(this._onDidChangeSessionsImmediately(e => this._onDevContainerSessionsChanged(e))); + } this.updateResourceLabelHomes(); const displayName = config.name || config.address; @@ -273,7 +288,21 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid } override async archiveSession(sessionId: string): Promise { - if (!this._hasSession(sessionId) || !this.connection) { + if (!this._hasSession(sessionId)) { + return; + } + if (this._devContainerLifecycle) { + await this._ensureDevContainerConnection(); + await this._setDevContainerSessionArchived(sessionId, true); + if (this.getKnownSessions().some(session => session.sessionId !== sessionId && !session.isArchived.get())) { + await this._stopDevContainerIfIdle(); + return; + } + await this._devContainerLifecycle.remove(); + await this._setDetachedWorktreeArchived(sessionId, true); + return; + } + if (!this.connection) { return; } await this._setDetachedWorktreeArchived(sessionId, true); @@ -283,7 +312,28 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid } override async unarchiveSession(sessionId: string): Promise { - if (!this._hasSession(sessionId) || !this.connection) { + if (!this._hasSession(sessionId)) { + return; + } + if (this._devContainerLifecycle) { + await this._setDetachedWorktreeArchived(sessionId, false); + try { + await this._ensureDevContainerConnection(); + await this._setDevContainerSessionArchived(sessionId, false); + } catch (error) { + const hasOtherUnarchivedSession = this.getKnownSessions().some(session => session.sessionId !== sessionId && !session.isArchived.get()); + if (!hasOtherUnarchivedSession) { + await this._devContainerLifecycle.remove(); + await this._setDetachedWorktreeArchived(sessionId, true); + } + throw error; + } + if (this.getSessions().find(session => session.sessionId === sessionId)?.status.get() === SessionStatus.Completed) { + await this._stopDevContainerIfIdle(); + } + return; + } + if (!this.connection) { return; } await this._setDetachedWorktreeArchived(sessionId, false); @@ -292,26 +342,128 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid } } + override async createNewChat(chatId: string): Promise { + await this._ensureDevContainerConnection(); + return super.createNewChat(chatId); + } + + override async sendRequest(chatId: string, chatResource: URI, options: ISendRequestOptions): Promise { + await this._ensureDevContainerConnection(); + return super.sendRequest(chatId, chatResource, options); + } + + private async _ensureDevContainerConnection(): Promise { + if (this.connection || !this._devContainerLifecycle) { + return; + } + await this._devContainerLifecycle.connect(); + if (!this.connection) { + throw new Error(localize('devContainerAgentHost.reconnectFailed', "Dev Container Agent Host '{0}' did not reconnect.", this.label)); + } + } + + private async _setDevContainerSessionArchived(sessionId: string, archived: boolean): Promise { + const connection = this.connection; + const backendUri = this._getBackendSessionUri(sessionId); + if (!connection || !backendUri) { + throw new Error(archived + ? localize('devContainerAgentHost.archiveDisconnected', "Unable to archive Dev Container session '{0}' while disconnected.", sessionId) + : localize('devContainerAgentHost.unarchiveDisconnected', "Unable to unarchive Dev Container session '{0}' while disconnected.", sessionId)); + } + const subscription = connection.getSubscription(StateComponents.Session, backendUri, 'RemoteAgentHostSessionsProvider.archive'); + try { + let timedOut = false; + const confirmation = raceTimeout( + Event.toPromise(Event.filter(connection.onDidAction, envelope => + envelope.channel === backendUri.toString() + && envelope.action.type === ActionType.SessionIsArchivedChanged + && envelope.action.isArchived === archived + )), + DEV_CONTAINER_ARCHIVE_CONFIRMATION_TIMEOUT_MS, + () => timedOut = true, + ); + if (!this._setSessionArchived(sessionId, archived)) { + throw new Error(archived + ? localize('devContainerAgentHost.archiveDisconnected', "Unable to archive Dev Container session '{0}' while disconnected.", sessionId) + : localize('devContainerAgentHost.unarchiveDisconnected', "Unable to unarchive Dev Container session '{0}' while disconnected.", sessionId)); + } + const confirmationEnvelope = await confirmation; + if (confirmationEnvelope?.rejectionReason) { + this._setSessionArchivedLocally(sessionId, !archived); + throw new Error(archived + ? localize('devContainerAgentHost.archiveRejected', "Unable to archive Dev Container session '{0}': {1}", sessionId, confirmationEnvelope.rejectionReason) + : localize('devContainerAgentHost.unarchiveRejected', "Unable to unarchive Dev Container session '{0}': {1}", sessionId, confirmationEnvelope.rejectionReason)); + } + if (timedOut) { + this._setSessionArchivedLocally(sessionId, !archived); + throw new Error(archived + ? localize('devContainerAgentHost.archiveTimeout', "Timed out waiting for Dev Container session '{0}' to be archived.", sessionId) + : localize('devContainerAgentHost.unarchiveTimeout', "Timed out waiting for Dev Container session '{0}' to be unarchived.", sessionId)); + } + } finally { + subscription.dispose(); + } + } + + private _onDevContainerSessionsChanged(event: { readonly added: readonly ISession[]; readonly removed: readonly ISession[]; readonly changed: readonly ISession[] }): void { + for (const session of event.removed) { + this._lastSessionStatuses.delete(session.sessionId); + } + let becameIdle = false; + for (const session of [...event.added, ...event.changed]) { + const previous = this._lastSessionStatuses.get(session.sessionId); + const current = session.status.get(); + this._lastSessionStatuses.set(session.sessionId, current); + becameIdle ||= previous !== undefined && isActiveSessionStatus(previous) && current === SessionStatus.Completed; + } + if (becameIdle && !this.getSessions().some(session => isActiveSessionStatus(session.status.get()))) { + void this._stopDevContainerIfIdle().catch(error => + this._logService.error(`[${this.id}] Failed to stop idle Dev Container.`, error)); + } + } + + private async _stopDevContainerIfIdle(): Promise { + if (this._devContainerLifecycle && !this.getKnownSessions().some(session => { + const status = session.status.get(); + return status === SessionStatus.Untitled || isActiveSessionStatus(status); + })) { + await this._devContainerLifecycle.stop(); + } + } + override async deleteSessions(sessionIds: readonly string[]): Promise { + const hadSessions = sessionIds.some(sessionId => this._hasSession(sessionId)); const detachedWorktrees = sessionIds.filter(sessionId => this._hasSession(sessionId)).map(sessionId => ({ sessionId, handle: this._getDetachedWorktreeHandle(sessionId), })).filter((entry): entry is { sessionId: string; handle: string } => !!entry.handle); let deleteError: unknown; try { + if (hadSessions && this._devContainerLifecycle) { + await this._ensureDevContainerConnection(); + } await super.deleteSessions(sessionIds); } catch (error) { deleteError = error; } let worktreeError: unknown; - for (const { sessionId, handle } of detachedWorktrees) { - if (this._hasSession(sessionId)) { - continue; - } + if (!deleteError && this._devContainerLifecycle && hadSessions && this.getKnownSessions().length === 0) { try { - await this._deleteDetachedWorktree(handle); + await this._devContainerLifecycle.remove(); } catch (error) { - worktreeError ??= error; + worktreeError = error; + } + } + if (!worktreeError) { + for (const { sessionId, handle } of detachedWorktrees) { + if (this._hasSession(sessionId)) { + continue; + } + try { + await this._deleteDetachedWorktree(handle); + } catch (error) { + worktreeError ??= error; + } } } if (deleteError) { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts index d5545d1f9704a..3b7719b6a1d88 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/devContainerAgentHostConnector.contribution.ts @@ -132,7 +132,15 @@ class DevContainerAgentHostConnector implements IDevContainerAgentHostConnector return isDevContainerWorkspaceAvailable(workspaceUri, this._fileService, this._mainService, this._configurationService); } - async createConnection(workspaceUri: URI, address: string, token: CancellationToken): Promise { + stopContainer(workspaceUri: URI): Promise { + return this._mainService.stopContainer(workspaceUri.fsPath); + } + + removeContainer(workspaceUri: URI): Promise { + return this._mainService.removeContainer(workspaceUri.fsPath); + } + + async createConnection(workspaceUri: URI, address: string, token: CancellationToken, options?: { readonly resume: boolean }): Promise { ensureDevContainerAgentHostsEnabled(this._configurationService); if (workspaceUri.scheme !== Schemas.file) { throw new Error(localize('devContainerAgentHost.localWorkspaceRequired', "Dev Container Agent Hosts require a local file workspace.")); @@ -151,6 +159,7 @@ class DevContainerAgentHostConnector implements IDevContainerAgentHostConnector connectionId, workspaceFolder, name, + resume: options?.resume ?? true, }); if (token.isCancellationRequested) { throw new CancellationError(); @@ -180,6 +189,7 @@ class DevContainerAgentHostConnector implements IDevContainerAgentHostConnector connectionId: reconnectConnectionId, workspaceFolder, name, + resume: false, }); return { connectionId: reconnectConnectionId, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/devContainerAgentHostService.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/devContainerAgentHostService.test.ts index 35b0625ad5b0b..321670ce1e9f7 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/devContainerAgentHostService.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/devContainerAgentHostService.test.ts @@ -143,6 +143,7 @@ class TestProvider extends mock() { defaultDirectory: string | undefined; status = RemoteAgentHostConnectionStatus.disconnected; disposed = false; + clearConnectionCalls = 0; constructor(readonly config: IRemoteAgentHostSessionsProviderConfig) { super(); @@ -158,6 +159,11 @@ class TestProvider extends mock() { this.status = status; } + override clearConnection(): void { + this.clearConnectionCalls++; + this.wiredConnection = undefined; + } + override dispose(): void { this.disposed = true; } @@ -329,6 +335,85 @@ suite('Dev Container Agent Host Service', () => { }); }); + test('stops an idle container, reconnects it on demand, and removes it without disposing the provider', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const remoteAgentHostService = store.add(new TestRemoteAgentHostService()); + const sessionsProvidersService = store.add(new TestSessionsProvidersService()); + const service = store.add(new TestDevContainerAgentHostService( + instantiationService, + remoteAgentHostService, + sessionsProvidersService, + )); + + const sourceWorkspace = URI.file('/source'); + const address = devContainerAddress(sourceWorkspace); + const connection = new TestAgentConnection(); + let connectorCalls = 0; + let failReconnect = false; + const containerOperations: string[] = []; + store.add(service.registerConnector({ + isAvailable: async () => true, + createConnection: async (_workspaceUri, stagedAddress) => { + connectorCalls++; + if (failReconnect) { + throw new CancellationError(); + } + return { + address: stagedAddress, + name: 'Source Dev Container', + transportFactory: () => undefined as never, + workspaceUri: URI.from({ + scheme: AGENT_HOST_SCHEME, + authority: agentHostAuthority(address), + path: '/workspaces/source', + }), + }; + }, + stopContainer: async workspaceUri => { containerOperations.push(`stop:${workspaceUri.toString()}`); }, + removeContainer: async workspaceUri => { containerOperations.push(`remove:${workspaceUri.toString()}`); }, + })); + instantiationService.stubInstance(AgentHostProtocolClient, connection); + + await service.connect(sourceWorkspace, CancellationToken.None); + const provider = service.provider!; + await provider.config.devContainerLifecycle!.stop(); + const afterStop = { + providerDisposed: provider.disposed, + registeredProviders: sessionsProvidersService.getProviders().length, + status: provider.status, + clearConnectionCalls: provider.clearConnectionCalls, + }; + failReconnect = true; + await assert.rejects(provider.config.devContainerLifecycle!.connect(), CancellationError); + failReconnect = false; + await provider.config.devContainerLifecycle!.connect(); + await provider.config.devContainerLifecycle!.remove(); + + assert.deepStrictEqual({ + afterStop, + connectorCalls, + containerOperations, + providerDisposed: provider.disposed, + registeredProviders: sessionsProvidersService.getProviders().length, + status: provider.status, + }, { + afterStop: { + providerDisposed: false, + status: RemoteAgentHostConnectionStatus.disconnected, + registeredProviders: 1, + clearConnectionCalls: 1, + }, + connectorCalls: 3, + containerOperations: [ + `stop:${sourceWorkspace.toString()}`, + `remove:${sourceWorkspace.toString()}`, + ], + providerDisposed: false, + registeredProviders: 1, + status: RemoteAgentHostConnectionStatus.disconnected, + }); + }); + test('a canceled caller stops waiting without canceling a shared connection', async () => { const instantiationService = store.add(new TestInstantiationService()); const remoteAgentHostService = store.add(new TestRemoteAgentHostService()); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index e73f66b5cc1c3..b9c73ec49d2fb 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -73,6 +73,8 @@ class MockAgentConnection extends mock() { public disposedSessions: URI[] = []; public dispatchedActions: { channel: string; action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; + public echoDispatchedActions = false; + public dispatchedActionRejectionReason: string | undefined; public resolveSessionConfigResult: ResolveSessionConfigResult = { schema: { type: 'object', properties: {} }, values: { isolation: 'worktree' } }; private _nextSeq = 0; @@ -124,6 +126,9 @@ class MockAgentConnection extends mock() { override dispatch(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { this.dispatchedActions.push({ channel, action, clientId: this.clientId, clientSeq: this._nextSeq++ }); + if (this.echoDispatchedActions) { + queueMicrotask(() => this.fireAction({ channel, action, serverSeq: this._nextSeq++, origin: undefined, rejectionReason: this.dispatchedActionRejectionReason } as ActionEnvelope)); + } } // Test helpers @@ -238,7 +243,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; }; } -function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; localAgentHostService?: IAgentHostService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon; defaultChangesetKind?: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; devContainerWorktreeScope?: string; ctor?: typeof RemoteAgentHostSessionsProvider; labelService?: ILabelService; defaultDirectory?: string }): RemoteAgentHostSessionsProvider { +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; preferenceKey?: string; connectionName?: string | undefined; sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise; openSession?: boolean; storageService?: IStorageService; localAgentHostService?: IAgentHostService; noConnection?: boolean; isWebPlatform?: boolean; workspaceTrusted?: boolean; omitHostFromWorkspaceLabel?: boolean; workspaceTypeIcon?: ThemeIcon; defaultChangesetKind?: IRemoteAgentHostSessionsProviderConfig['defaultChangesetKind']; devContainerWorktreeScope?: string; devContainerLifecycle?: IRemoteAgentHostSessionsProviderConfig['devContainerLifecycle']; ctor?: typeof RemoteAgentHostSessionsProvider; labelService?: ILabelService; defaultDirectory?: string }): RemoteAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IFileDialogService, {}); @@ -296,6 +301,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne workspaceTypeIcon: overrides?.workspaceTypeIcon, defaultChangesetKind: overrides?.defaultChangesetKind, devContainerWorktreeScope: overrides?.devContainerWorktreeScope, + devContainerLifecycle: overrides?.devContainerLifecycle, }; const baseCtor = overrides?.ctor ?? RemoteAgentHostSessionsProvider; @@ -326,7 +332,7 @@ async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, s }); } -function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; createdAt?: string; modifiedAt?: string; metadata?: Record }): void { +function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; createdAt?: string; modifiedAt?: string; metadata?: Record; status?: ProtocolSessionStatus }): void { const provider = opts?.provider ?? 'copilotcli'; const sessionUri = AgentSession.uri(provider, rawId); connection.fireNotification({ @@ -336,7 +342,7 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: resource: sessionUri.toString(), provider, title: opts?.title ?? `Session ${rawId}`, - status: ProtocolSessionStatus.Idle, + status: opts?.status ?? ProtocolSessionStatus.Idle, createdAt: opts?.createdAt ?? new Date().toISOString(), modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, @@ -346,6 +352,15 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: }); } +function fireSessionSummaryChanged(connection: MockAgentConnection, rawId: string, status: ProtocolSessionStatus, provider = 'copilotcli'): void { + connection.fireNotification({ + channel: 'ahp-root://', + type: NotificationType.SessionSummaryChanged, + session: AgentSession.uri(provider, rawId).toString(), + changes: { status }, + }); +} + function fireSessionRemoved(connection: MockAgentConnection, rawId: string, provider = 'copilotcli'): void { const sessionUri = AgentSession.uri(provider, rawId); connection.fireNotification({ @@ -783,19 +798,161 @@ suite('RemoteAgentHostSessionsProvider', () => { await unarchiveProvider.unarchiveSession(sessionToUnarchive.sessionId); const archiveConnection = new MockAgentConnection(); - const archiveProvider = createProvider(disposables, archiveConnection, { localAgentHostService }); + archiveConnection.echoDispatchedActions = true; + archiveConnection.addSession(createSession('dev-container-worktree-archive', { summary: 'Dev Container Worktree Archive', _meta: metadata })); + const archiveState: { provider?: RemoteAgentHostSessionsProvider } = {}; + const archiveProvider = createProvider(disposables, archiveConnection, { + localAgentHostService, + devContainerLifecycle: { + connect: async () => { + delegated.push('connect-container'); + archiveState.provider!.setConnection(archiveConnection); + }, + stop: async () => { }, + remove: async () => { delegated.push('remove-container'); }, + }, + }); + archiveState.provider = archiveProvider; fireSessionAdded(archiveConnection, 'dev-container-worktree-archive', { title: 'Dev Container Worktree Archive', metadata }); const sessionToArchive = archiveProvider.getSessions().find(candidate => candidate.title.get() === 'Dev Container Worktree Archive'); assert.ok(sessionToArchive); + archiveProvider.clearConnection(); await archiveProvider.archiveSession(sessionToArchive.sessionId); assert.deepStrictEqual(delegated, [ `delete:${handle}`, `unarchive:${handle}`, + 'connect-container', + 'remove-container', `archive:${handle}`, ]); }); + test('stops a Dev Container after all active sessions become idle', async () => { + const lifecycleCalls: string[] = []; + const provider = createProvider(disposables, connection, { + devContainerLifecycle: { + connect: async () => { lifecycleCalls.push('connect'); }, + stop: async () => { + lifecycleCalls.push('stop'); + }, + remove: async () => { lifecycleCalls.push('remove'); }, + }, + }); + await timeout(0); + connection.addSession({ ...createSession('idle-container'), status: ProtocolSessionStatus.InProgress }); + connection.addSession({ ...createSession('still-active-container'), status: ProtocolSessionStatus.InProgress }); + fireSessionAdded(connection, 'idle-container', { status: ProtocolSessionStatus.InProgress }); + fireSessionAdded(connection, 'still-active-container', { status: ProtocolSessionStatus.InProgress }); + const session = provider.getSessions()[0]; + assert.ok(session); + + connection.addSession({ ...createSession('idle-container'), status: ProtocolSessionStatus.Idle }); + fireSessionSummaryChanged(connection, 'idle-container', ProtocolSessionStatus.Idle); + await timeout(0); + assert.deepStrictEqual(lifecycleCalls, []); + connection.addSession({ ...createSession('still-active-container'), status: ProtocolSessionStatus.Idle }); + fireSessionSummaryChanged(connection, 'still-active-container', ProtocolSessionStatus.Idle); + await timeout(0); + + assert.deepStrictEqual({ + lifecycleCalls, + status: session.status.get(), + statuses: provider.getSessions().map(candidate => candidate.status.get()), + }, { + lifecycleCalls: ['stop'], + status: SessionStatus.Completed, + statuses: [SessionStatus.Completed, SessionStatus.Completed], + }); + }); + + test('keeps a Dev Container running while an unsent draft exists', async () => { + const lifecycleCalls: string[] = []; + const provider = createProvider(disposables, connection, { + devContainerLifecycle: { + connect: async () => { lifecycleCalls.push('connect'); }, + stop: async () => { lifecycleCalls.push('stop'); }, + remove: async () => { lifecycleCalls.push('remove'); }, + }, + }); + await timeout(0); + connection.addSession({ ...createSession('idle-with-draft'), status: ProtocolSessionStatus.InProgress }); + fireSessionAdded(connection, 'idle-with-draft', { status: ProtocolSessionStatus.InProgress }); + const draft = provider.createNewSession( + URI.parse('vscode-agent-host://localhost__4321/home/user/project'), + provider.sessionTypes[0].id, + ); + + connection.addSession({ ...createSession('idle-with-draft'), status: ProtocolSessionStatus.Idle }); + fireSessionSummaryChanged(connection, 'idle-with-draft', ProtocolSessionStatus.Idle); + await timeout(0); + + assert.deepStrictEqual(lifecycleCalls, []); + provider.deleteNewSession(draft.sessionId); + }); + + test('does not remove a Dev Container or worktree when the host rejects archive', async () => { + const handle = '00000000-0000-4000-8000-000000000001'; + const metadata = { 'vscode.devContainerWorktree': { version: 1, handle } }; + connection.echoDispatchedActions = true; + connection.dispatchedActionRejectionReason = 'archive denied'; + connection.addSession(createSession('rejected-archive', { summary: 'Rejected Archive', _meta: metadata })); + const operations: string[] = []; + const provider = createProvider(disposables, connection, { + localAgentHostService: new class extends mock() { + override async setDetachedWorktreeArchived(): Promise { + operations.push('archive-worktree'); + } + }(), + devContainerLifecycle: { + connect: async () => { operations.push('connect'); }, + stop: async () => { operations.push('stop'); }, + remove: async () => { operations.push('remove-container'); }, + }, + }); + fireSessionAdded(connection, 'rejected-archive', { title: 'Rejected Archive', metadata }); + const session = provider.getSessions().find(candidate => candidate.title.get() === 'Rejected Archive'); + assert.ok(session); + + await assert.rejects(provider.archiveSession(session.sessionId), /archive denied/); + + assert.deepStrictEqual({ + operations, + archived: session.isArchived.get(), + }, { + operations: [], + archived: false, + }); + }); + + test('does not remove a shared Dev Container when another session remains unarchived', async () => { + connection.echoDispatchedActions = true; + connection.addSession(createSession('shared-archive', { summary: 'Shared Archive' })); + connection.addSession(createSession('shared-remaining', { summary: 'Shared Remaining' })); + const operations: string[] = []; + const provider = createProvider(disposables, connection, { + devContainerLifecycle: { + connect: async () => { operations.push('connect'); }, + stop: async () => { operations.push('stop'); }, + remove: async () => { operations.push('remove-container'); }, + }, + }); + fireSessionAdded(connection, 'shared-archive', { title: 'Shared Archive' }); + fireSessionAdded(connection, 'shared-remaining', { title: 'Shared Remaining' }); + const session = provider.getSessions().find(candidate => candidate.title.get() === 'Shared Archive'); + assert.ok(session); + + await provider.archiveSession(session.sessionId); + + assert.deepStrictEqual({ + operations, + archived: session.isArchived.get(), + }, { + operations: ['stop'], + archived: true, + }); + }); + test('deletes a detached Dev Container worktree when its draft is abandoned', async () => { const handle = '00000000-0000-4000-8000-000000000001'; const deleted = new DeferredPromise();