From 36627f957754be448272adc1b23af0bc79838b91 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 26 May 2026 13:47:51 +1000 Subject: [PATCH 1/4] feat: Introduce ChangesetFileMonitorCoordinator for session-specific file monitoring - Added ChangesetFileMonitorCoordinator to manage file monitoring for changesets. - Integrated ChangesetFileMonitorCoordinator into ChangesetSessionCoordinator. - Updated ChangesetSessionCoordinator to handle session restoration, materialization, and disposal with file monitoring. - Implemented IAgentHostFileMonitorService to provide file monitoring capabilities. - Created AgentHostFileMonitorService to manage file watching with debounce and exclusion options. - Added tests for ChangesetSessionCoordinator and AgentHostFileMonitorService to ensure correct functionality. --- .../node/agentHostChangesetCoordinator.ts | 30 +- ...gentHostChangesetFileMonitorCoordinator.ts | 355 ++++++++++++++++ .../node/agentHostFileMonitorService.ts | 181 +++++++++ .../platform/agentHost/node/agentHostMain.ts | 5 +- .../agentHost/node/agentHostServerMain.ts | 5 +- .../agentHost/node/agentHostStateManager.ts | 4 + .../platform/agentHost/node/agentService.ts | 7 +- .../agentHostChangesetCoordinator.test.ts | 378 ++++++++++++++++++ .../node/agentHostFileMonitorService.test.ts | 190 +++++++++ .../test/node/agentHostStateManager.test.ts | 60 +++ 10 files changed, 1211 insertions(+), 4 deletions(-) create mode 100644 src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts create mode 100644 src/vs/platform/agentHost/node/agentHostFileMonitorService.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostFileMonitorService.test.ts diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 6b5286c08ca03..15f78ca7d1767 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -14,7 +14,11 @@ import { } from '../common/changesetUri.js'; import { ChangesetStatus } from '../common/state/sessionState.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { ChangesetFileMonitorCoordinator } from './agentHostChangesetFileMonitorCoordinator.js'; +import { IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; +import { IAgentHostGitService } from './agentHostGitService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; +import { ILogService } from '../../log/common/log.js'; import { buildCatalogueFromLiveState, buildCatalogueFromPersistedDiffs, @@ -79,13 +83,18 @@ export class ChangesetSessionCoordinator extends Disposable { * pure waste). */ private readonly _subscribedTurns = new Map>(); + private readonly _changesetFileMonitor: ChangesetFileMonitorCoordinator; constructor( private readonly _stateManager: AgentHostStateManager, private readonly _changesets: IAgentHostChangesetService, private readonly _configurationService: IAgentConfigurationService, + fileMonitorService: IAgentHostFileMonitorService, + gitService: IAgentHostGitService, + logService: ILogService, ) { super(); + this._changesetFileMonitor = this._register(new ChangesetFileMonitorCoordinator(this._stateManager, this._changesets, this._configurationService, fileMonitorService, gitService, logService)); this._changesets.setTurnSubscriberProbe((session, turnId) => this.hasTurnSubscribers(session, turnId)); } @@ -134,6 +143,7 @@ export class ChangesetSessionCoordinator extends Disposable { // drain the deferred refresh. Idempotent — the per-session // sequencer collapses overlapping computes. this._drainPendingRefresh(sessionStr); + this._changesetFileMonitor.onSessionRestored(sessionStr); } /** @@ -143,6 +153,7 @@ export class ChangesetSessionCoordinator extends Disposable { */ onSessionMaterialized(sessionStr: string): void { this._drainPendingRefresh(sessionStr); + this._changesetFileMonitor.onSessionMaterialized(sessionStr); } /** @@ -152,6 +163,11 @@ export class ChangesetSessionCoordinator extends Disposable { onSessionDisposed(sessionStr: string): void { this._pendingUncommittedRefreshes.delete(sessionStr); this._subscribedTurns.delete(sessionStr); + this._changesetFileMonitor.onSessionDisposed(sessionStr); + } + + onSessionTurnActiveChanged(sessionStr: string, active: boolean): void { + this._changesetFileMonitor.onSessionTurnActiveChanged(sessionStr, active); } // ---- Subscription hooks ------------------------------------------------- @@ -170,6 +186,7 @@ export class ChangesetSessionCoordinator extends Disposable { const parsed = parseChangesetUri(resourceStr); if (parsed?.kind === ChangesetKind.Uncommitted) { this._triggerUncommittedRefresh(parsed.sessionUri); + this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } if (parsed?.kind === ChangesetKind.Session) { @@ -177,6 +194,7 @@ export class ChangesetSessionCoordinator extends Disposable { // available and falls back to the SDK edit-tracker otherwise, // so it doesn't need the same deferral as uncommitted. this._changesets.refreshSessionChangeset(parsed.sessionUri); + this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined) { @@ -202,6 +220,7 @@ export class ChangesetSessionCoordinator extends Disposable { // editing files manually in the working tree. this._triggerUncommittedRefresh(resourceStr); this._changesets.refreshSessionChangeset(resourceStr); + this._changesetFileMonitor.trackSessionChanges(resourceStr, resourceStr); } } @@ -211,9 +230,15 @@ export class ChangesetSessionCoordinator extends Disposable { * subscribed anymore, there's no point firing it on materialize. */ onLastSubscriber(resource: URI): void { - const parsed = parseChangesetUri(resource.toString()); + const resourceStr = resource.toString(); + const parsed = parseChangesetUri(resourceStr); if (parsed?.kind === ChangesetKind.Uncommitted) { this._pendingUncommittedRefreshes.delete(parsed.sessionUri); + this._changesetFileMonitor.untrackSessionChanges(resourceStr); + return; + } + if (parsed?.kind === ChangesetKind.Session) { + this._changesetFileMonitor.untrackSessionChanges(resourceStr); return; } if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined) { @@ -225,6 +250,9 @@ export class ChangesetSessionCoordinator extends Disposable { } } } + if (!parsed) { + this._changesetFileMonitor.untrackSessionChanges(resourceStr); + } } /** diff --git a/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts new file mode 100644 index 0000000000000..d383b8adfecb8 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts @@ -0,0 +1,355 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SequencerByKey } from '../../../base/common/async.js'; +import { Disposable, DisposableMap, IReference, ReferenceCollection } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../common/changesetUri.js'; +import { parseSubagentSessionUri } from '../common/state/sessionState.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentHostChangesetService } from './agentHostChangesetService.js'; +import { DEFAULT_AGENT_HOST_WATCH_EXCLUDES, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; +import { IAgentHostGitService } from './agentHostGitService.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; +import { ILogService } from '../../log/common/log.js'; + +class WatchInterestReferenceCollection extends ReferenceCollection { + constructor( + private readonly _create: (sessionStr: string) => void, + private readonly _destroy: (sessionStr: string) => void, + ) { + super(); + } + + protected createReferencedObject(sessionStr: string): string { + this._create(sessionStr); + return sessionStr; + } + + protected destroyReferencedObject(sessionStr: string): void { + this._destroy(sessionStr); + } +} + +/** + * Keeps static changeset catalogue entries fresh while a client is observing a + * session or one of its static changeset resources. + * + * The generic {@link IAgentHostFileMonitorService} owns folder watching and + * debounce mechanics; this coordinator owns the changeset-specific lifecycle: + * subscription interest, session materialization, repository-root resolution, + * root-level watcher sharing, and refresh fanout. + */ +export class ChangesetFileMonitorCoordinator extends Disposable { + + /** Per-subscription references into the per-session watch-interest collection. */ + private readonly _watchInterestReferences = this._register(new DisposableMap>()); + private readonly _watchInterestCollection = new WatchInterestReferenceCollection( + sessionStr => this._attachWatcherIfPossible(sessionStr), + sessionStr => this._destroyWatchInterest(sessionStr), + ); + /** Sessions waiting for materialization before a root watcher can attach. */ + private readonly _pendingWatchInterest = new Set(); + /** Session URI string to the working directory that produced the current root attachment. */ + private readonly _sessionWorkingDirectory = new Map(); + /** Session URI string to repository-root URI string. */ + private readonly _sessionRoot = new Map(); + /** Repository-root URI string to sessions currently fanned out from that root. */ + private readonly _rootSessions = new Map>(); + /** Repository-root URI string to the shared monitor acquisition. */ + private readonly _rootWatchAcquisitions = this._register(new DisposableMap()); + /** Repository-root URI string to the canonical repository root URI. */ + private readonly _rootUris = new Map(); + /** Active session URI string to repository-root URI string. */ + private readonly _activeSessionRoots = new Map(); + /** Repository-root URI string to sessions currently active against that root. */ + private readonly _rootActiveSessions = new Map>(); + /** Active sessions whose repository root cannot yet be resolved. */ + private readonly _unresolvedActiveSessions = new Set(); + private readonly _watchAttachmentSequencer = new SequencerByKey(); + private readonly _activeTurnSequencer = new SequencerByKey(); + + constructor( + private readonly _stateManager: AgentHostStateManager, + private readonly _changesets: IAgentHostChangesetService, + private readonly _configurationService: IAgentConfigurationService, + private readonly _fileMonitorService: IAgentHostFileMonitorService, + private readonly _gitService: IAgentHostGitService, + private readonly _logService: ILogService, + ) { + super(); + } + + trackSessionChanges(subscriptionKey: string, sessionStr: string): void { + if (!this._watchInterestReferences.has(subscriptionKey)) { + this._watchInterestReferences.set(subscriptionKey, this._watchInterestCollection.acquire(sessionStr)); + } + } + + untrackSessionChanges(subscriptionKey: string): void { + this._watchInterestReferences.deleteAndDispose(subscriptionKey); + } + + onSessionRestored(sessionStr: string): void { + this._retryWatchAttachment(sessionStr); + } + + onSessionMaterialized(sessionStr: string): void { + this._retryWatchAttachment(sessionStr); + } + + onSessionDisposed(sessionStr: string): void { + this.untrackSessionChanges(buildUncommittedChangesetUri(sessionStr)); + this.untrackSessionChanges(buildSessionChangesetUri(sessionStr)); + this.untrackSessionChanges(sessionStr); + this._removeActiveSession(sessionStr); + this._destroyWatchInterest(sessionStr); + } + + onSessionTurnActiveChanged(sessionStr: string, active: boolean): void { + this._activeTurnSequencer.queue(sessionStr, async () => { + if (active) { + await this._markSessionActive(sessionStr); + } else { + this._markSessionInactive(sessionStr); + } + }); + } + + private _destroyWatchInterest(sessionStr: string): void { + this._pendingWatchInterest.delete(sessionStr); + this._releaseSessionRoot(sessionStr); + } + + private _retryWatchAttachment(sessionStr: string): void { + if (this._shouldAttachSession(sessionStr) || this._pendingWatchInterest.has(sessionStr)) { + this._attachWatcherIfPossible(sessionStr); + } + } + + private _hasWatchInterest(sessionStr: string): boolean { + return this._watchInterestReferences.has(sessionStr) + || this._watchInterestReferences.has(buildUncommittedChangesetUri(sessionStr)) + || this._watchInterestReferences.has(buildSessionChangesetUri(sessionStr)); + } + + private _attachWatcherIfPossible(sessionStr: string): void { + this._watchAttachmentSequencer.queue(sessionStr, async () => { + if (!this._shouldAttachSession(sessionStr)) { + return; + } + const workingDirectory = this._configurationService.getEffectiveWorkingDirectory(sessionStr); + if (!workingDirectory) { + this._pendingWatchInterest.add(sessionStr); + this._releaseSessionRoot(sessionStr); + return; + } + let workingDirectoryUri: URI; + try { + workingDirectoryUri = URI.parse(workingDirectory); + } catch (err) { + this._logService.warn(`[ChangesetFileMonitorCoordinator] Failed to parse working directory URI for ${sessionStr}: ${workingDirectory}`, err); + this._pendingWatchInterest.add(sessionStr); + this._releaseSessionRoot(sessionStr); + return; + } + if (this._sessionRoot.has(sessionStr) && this._sessionWorkingDirectory.get(sessionStr) === workingDirectory) { + this._pendingWatchInterest.delete(sessionStr); + return; + } + const repositoryRoot = await this._gitService.getRepositoryRoot(workingDirectoryUri); + if (!this._shouldAttachSession(sessionStr)) { + return; + } + if (!repositoryRoot) { + this._pendingWatchInterest.delete(sessionStr); + this._releaseSessionRoot(sessionStr); + return; + } + this._pendingWatchInterest.delete(sessionStr); + this._attachSessionToRoot(sessionStr, repositoryRoot, workingDirectory); + }); + } + + private _attachSessionToRoot(sessionStr: string, repositoryRoot: URI, workingDirectory: string): void { + const rootStr = repositoryRoot.toString(); + if (this._sessionRoot.get(sessionStr) === rootStr) { + this._sessionWorkingDirectory.set(sessionStr, workingDirectory); + this._ensureRootWatcher(rootStr, repositoryRoot); + return; + } + this._releaseSessionRoot(sessionStr); + let sessions = this._rootSessions.get(rootStr); + if (!sessions) { + sessions = new Set(); + this._rootSessions.set(rootStr, sessions); + this._rootUris.set(rootStr, repositoryRoot); + } + sessions.add(sessionStr); + this._sessionRoot.set(sessionStr, rootStr); + this._sessionWorkingDirectory.set(sessionStr, workingDirectory); + this._ensureRootWatcher(rootStr, repositoryRoot); + } + + private _releaseSessionRoot(sessionStr: string): void { + const rootStr = this._sessionRoot.get(sessionStr); + if (!rootStr) { + this._sessionWorkingDirectory.delete(sessionStr); + return; + } + this._sessionRoot.delete(sessionStr); + this._sessionWorkingDirectory.delete(sessionStr); + const sessions = this._rootSessions.get(rootStr); + if (!sessions) { + return; + } + sessions.delete(sessionStr); + if (sessions.size === 0) { + this._rootSessions.delete(rootStr); + this._rootUris.delete(rootStr); + this._rootWatchAcquisitions.deleteAndDispose(rootStr); + } + } + + private _onRootChanged(rootStr: string): void { + if (this._isRootActive(rootStr)) { + return; + } + const sessions = this._rootSessions.get(rootStr); + if (!sessions || sessions.size === 0) { + return; + } + const activeSessions = [...sessions].filter(session => { + return this._hasWatchInterest(session) + && this._sessionRoot.get(session) === rootStr + && !this._activeSessionRoots.has(session) + && !this._unresolvedActiveSessions.has(session) + && !!this._stateManager.getSessionState(session); + }); + if (activeSessions.length === 0) { + return; + } + for (const session of activeSessions) { + this._changesets.refreshUncommittedChangeset(session); + this._changesets.refreshSessionChangeset(session); + } + } + + private _shouldAttachSession(sessionStr: string): boolean { + return this._hasWatchInterest(sessionStr) + && !this._activeSessionRoots.has(sessionStr) + && !this._unresolvedActiveSessions.has(sessionStr); + } + + private _isRootActive(rootStr: string): boolean { + return (this._rootActiveSessions.get(rootStr)?.size ?? 0) > 0; + } + + private _ensureRootWatcher(rootStr: string, repositoryRoot: URI): void { + if (this._isRootActive(rootStr) || this._rootWatchAcquisitions.has(rootStr)) { + return; + } + const sessions = this._rootSessions.get(rootStr); + if (!sessions || sessions.size === 0) { + return; + } + const rootWatchAcquisition = this._fileMonitorService.acquire(repositoryRoot, () => this._onRootChanged(rootStr), { + excludes: DEFAULT_AGENT_HOST_WATCH_EXCLUDES, + debounceMs: 750, + }); + if (!rootWatchAcquisition) { + for (const session of sessions) { + this._pendingWatchInterest.add(session); + } + return; + } + this._rootWatchAcquisitions.set(rootStr, rootWatchAcquisition); + } + + private _suspendRootWatcher(rootStr: string): void { + this._rootWatchAcquisitions.deleteAndDispose(rootStr); + } + + private async _markSessionActive(sessionStr: string): Promise { + this._removeActiveSession(sessionStr); + this._pendingWatchInterest.delete(sessionStr); + const repositoryRoot = await this._resolveActivityRepositoryRoot(sessionStr); + if (!repositoryRoot) { + this._unresolvedActiveSessions.add(sessionStr); + this._releaseSessionRoot(sessionStr); + return; + } + const rootStr = repositoryRoot.toString(); + let activeSessions = this._rootActiveSessions.get(rootStr); + if (!activeSessions) { + activeSessions = new Set(); + this._rootActiveSessions.set(rootStr, activeSessions); + } + activeSessions.add(sessionStr); + this._activeSessionRoots.set(sessionStr, rootStr); + this._rootUris.set(rootStr, repositoryRoot); + this._suspendRootWatcher(rootStr); + if (this._sessionRoot.get(sessionStr) !== rootStr) { + this._releaseSessionRoot(sessionStr); + } + } + + private _markSessionInactive(sessionStr: string): void { + const rootStr = this._removeActiveSession(sessionStr); + if (rootStr) { + const repositoryRoot = this._rootUris.get(rootStr); + if (repositoryRoot) { + this._ensureRootWatcher(rootStr, repositoryRoot); + } + } + if (this._hasWatchInterest(sessionStr) || this._pendingWatchInterest.has(sessionStr)) { + this._attachWatcherIfPossible(sessionStr); + } + } + + private _removeActiveSession(sessionStr: string): string | undefined { + this._unresolvedActiveSessions.delete(sessionStr); + const rootStr = this._activeSessionRoots.get(sessionStr); + if (!rootStr) { + return undefined; + } + this._activeSessionRoots.delete(sessionStr); + const activeSessions = this._rootActiveSessions.get(rootStr); + if (activeSessions) { + activeSessions.delete(sessionStr); + if (activeSessions.size === 0) { + this._rootActiveSessions.delete(rootStr); + } + } + return rootStr; + } + + private async _resolveActivityRepositoryRoot(sessionStr: string): Promise { + const workingDirectory = this._getActivityWorkingDirectory(sessionStr); + if (!workingDirectory) { + return undefined; + } + let workingDirectoryUri: URI; + try { + workingDirectoryUri = URI.parse(workingDirectory); + } catch (err) { + this._logService.warn(`[ChangesetFileMonitorCoordinator] Failed to parse active working directory URI for ${sessionStr}: ${workingDirectory}`, err); + return undefined; + } + return this._gitService.getRepositoryRoot(workingDirectoryUri); + } + + private _getActivityWorkingDirectory(sessionStr: string): string | undefined { + const workingDirectory = this._configurationService.getEffectiveWorkingDirectory(sessionStr); + if (workingDirectory) { + return workingDirectory; + } + const parsedSubagent = parseSubagentSessionUri(sessionStr); + if (!parsedSubagent) { + return undefined; + } + return this._configurationService.getEffectiveWorkingDirectory(parsedSubagent.parentSession.toString()); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts b/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts new file mode 100644 index 0000000000000..b7637dc587822 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../base/common/async.js'; +import { IExpression, ParsedExpression, parse } from '../../../base/common/glob.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { FileChangesEvent, IFileService } from '../../files/common/files.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; + +export const IAgentHostFileMonitorService = createDecorator('agentHostFileMonitorService'); + +export const DEFAULT_AGENT_HOST_WATCH_EXCLUDES: readonly string[] = Object.freeze([ + '**/.git/objects/**', + '**/.git/subtree-cache/**', + '**/.git/**/*.lock', + '**/.hg/store/**', + '**/*.watchman-cookie-*', +]); + +export interface IAgentHostFileMonitorOptions { + readonly excludes?: readonly string[]; + readonly debounceMs?: number; +} + +export interface IAgentHostFileMonitorService extends IDisposable { + readonly _serviceBrand: undefined; + acquire(folder: URI, callback: () => void, options?: IAgentHostFileMonitorOptions): IDisposable | undefined; +} + +interface IMonitorEntry extends IDisposable { + readonly folder: URI; + readonly callbacks: Set<() => void>; + readonly debounce: MutableDisposable; + readonly debounceMs: number; + readonly excludeMatcher: ParsedExpression; +} + +function normalizeExcludes(excludes: readonly string[]): readonly string[] { + return [...excludes].sort(); +} + +function parseExcludes(excludes: readonly string[]): ParsedExpression { + const expression: IExpression = Object.create(null); + for (const exclude of excludes) { + expression[exclude] = true; + } + return parse(expression); +} + +export class AgentHostFileMonitorService extends Disposable implements IAgentHostFileMonitorService { + declare readonly _serviceBrand: undefined; + + private static readonly _DEFAULT_DEBOUNCE_MS = 750; + + private readonly _entries = this._register(new DisposableMap()); + + constructor( + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._register(this._fileService.onDidFilesChange(event => this._onDidFilesChange(event))); + this._register(this._fileService.onDidWatchError(error => { + this._logService.warn('[AgentHostFileMonitorService] File watcher error', error); + })); + } + + acquire(folder: URI, callback: () => void, options: IAgentHostFileMonitorOptions = {}): IDisposable | undefined { + const canonicalFolder = this._canonicalizeFolder(folder); + const excludes = normalizeExcludes(options.excludes ?? DEFAULT_AGENT_HOST_WATCH_EXCLUDES); + const debounceMs = options.debounceMs ?? AgentHostFileMonitorService._DEFAULT_DEBOUNCE_MS; + const key = this._key(canonicalFolder, excludes, debounceMs); + + let entry = this._entries.get(key); + if (!entry) { + try { + entry = this._createEntry(key, canonicalFolder, excludes, debounceMs); + } catch (err) { + this._logService.warn(`[AgentHostFileMonitorService] Failed to watch ${canonicalFolder.toString()}`, err); + return undefined; + } + this._entries.set(key, entry); + } + + entry.callbacks.add(callback); + return toDisposable(() => { + const current = this._entries.get(key); + if (!current) { + return; + } + current.callbacks.delete(callback); + if (current.callbacks.size === 0) { + this._entries.deleteAndDispose(key); + } + }); + } + + private _createEntry(key: string, folder: URI, excludes: readonly string[], debounceMs: number): IMonitorEntry { + const disposable = new DisposableStore(); + try { + const debounce = disposable.add(new MutableDisposable()); + const callbacks = new Set<() => void>(); + const excludeMatcher = parseExcludes(excludes); + disposable.add(this._fileService.watch(folder, { recursive: true, excludes: [...excludes] })); + return { folder, callbacks, debounce, debounceMs, excludeMatcher, dispose: () => disposable.dispose() }; + } catch (err) { + disposable.dispose(); + throw err; + } + } + + private _onDidFilesChange(event: FileChangesEvent): void { + for (const key of this._entries.keys()) { + this._onDidFilesChangeEntry(key, event); + } + } + + private _onDidFilesChangeEntry(key: string, event: FileChangesEvent): void { + const entry = this._entries.get(key); + if (!entry || entry.callbacks.size === 0) { + return; + } + if (!event.affects(entry.folder) || !this._hasRelevantRawChange(entry, event)) { + return; + } + entry.debounce.value = disposableTimeout(() => { + entry.debounce.clear(); + for (const callback of [...entry.callbacks]) { + try { + callback(); + } catch (err) { + this._logService.warn('[AgentHostFileMonitorService] Folder change callback failed', err); + } + } + }, entry.debounceMs); + } + + private _hasRelevantRawChange(entry: IMonitorEntry, event: FileChangesEvent): boolean { + return this._hasRelevantRawResources(entry, event.rawAdded) + || this._hasRelevantRawResources(entry, event.rawUpdated) + || this._hasRelevantRawResources(entry, event.rawDeleted); + } + + private _hasRelevantRawResources(entry: IMonitorEntry, resources: readonly URI[]): boolean { + for (const resource of resources) { + if (!extUriBiasedIgnorePathCase.isEqualOrParent(resource, entry.folder)) { + continue; + } + if (!this._isExcluded(entry, resource)) { + return true; + } + } + return false; + } + + private _isExcluded(entry: IMonitorEntry, resource: URI): boolean { + const basename = extUriBiasedIgnorePathCase.basename(resource); + const relativePath = extUriBiasedIgnorePathCase.relativePath(entry.folder, resource); + if (relativePath !== undefined && this._matchesExclude(entry, relativePath, basename)) { + return true; + } + return this._matchesExclude(entry, resource.path, basename); + } + + private _matchesExclude(entry: IMonitorEntry, path: string, basename: string): boolean { + return typeof entry.excludeMatcher(path, basename) === 'string'; + } + + private _canonicalizeFolder(folder: URI): URI { + return extUriBiasedIgnorePathCase.removeTrailingPathSeparator(extUriBiasedIgnorePathCase.normalizePath(folder)); + } + + private _key(folder: URI, excludes: readonly string[], debounceMs: number): string { + return `${extUriBiasedIgnorePathCase.getComparisonKey(folder)}\u0000${debounceMs}\u0000${excludes.join('\n')}`; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index bab96278c87fb..3bbd6447aad4b 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -63,6 +63,7 @@ import { AgentPluginManager } from './agentPluginManager.js'; import { AgentHostGitService, IAgentHostGitService } from './agentHostGitService.js'; import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; +import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; import { join } from '../../../base/common/path.js'; import { createAgentHostTelemetryService } from './agentHostTelemetryService.js'; @@ -134,6 +135,8 @@ async function startAgentHost(): Promise { diServices.set(IProductService, productService); diServices.set(ITelemetryService, telemetryService); instantiationService = new InstantiationService(diServices); + const fileMonitorService = disposables.add(instantiationService.createInstance(AgentHostFileMonitorService)); + diServices.set(IAgentHostFileMonitorService, fileMonitorService); diServices.set(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); diServices.set(ISandboxHelperService, new SandboxHelperService()); const gitService = instantiationService.createInstance(AgentHostGitService); @@ -152,7 +155,7 @@ async function startAgentHost(): Promise { diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); - agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService); + agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); diServices.set(IAgentPluginManager, pluginManager); const diffComputeService = disposables.add(new NodeWorkerDiffComputeService(logService)); diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 3da5234e18507..5371e66762402 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -66,6 +66,7 @@ import { registerPendingEditContentProvider } from './copilot/pendingEditContent import { AgentHostGitService, IAgentHostGitService } from './agentHostGitService.js'; import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; +import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { createAgentHostTelemetryService } from './agentHostTelemetryService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; @@ -208,6 +209,8 @@ async function main(): Promise { diServices.set(ISessionDataService, sessionDataService); diServices.set(ITelemetryService, telemetryService); const instantiationService = new InstantiationService(diServices); + const fileMonitorService = disposables.add(instantiationService.createInstance(AgentHostFileMonitorService)); + diServices.set(IAgentHostFileMonitorService, fileMonitorService); diServices.set(IWindowsMxcTerminalSandboxRuntime, instantiationService.createInstance(WindowsMxcTerminalSandboxRuntime)); diServices.set(ISandboxHelperService, new SandboxHelperService()); const gitService = instantiationService.createInstance(AgentHostGitService); @@ -216,7 +219,7 @@ async function main(): Promise { diServices.set(IAgentHostCheckpointService, checkpointService); // Create the agent service (owns AgentHostStateManager + AgentSideEffects internally) - const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService); + const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); disposables.add(agentService); // Register agents diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index a46217a784d4d..de577ea5672e1 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -85,6 +85,8 @@ export class AgentHostStateManager extends Disposable { private readonly _onDidEmitNotification = this._register(new Emitter()); readonly onDidEmitNotification: Event = this._onDidEmitNotification.event; + private readonly _onDidChangeSessionActiveTurn = this._register(new Emitter<{ session: string; active: boolean }>()); + readonly onDidChangeSessionActiveTurn: Event<{ session: string; active: boolean }> = this._onDidChangeSessionActiveTurn.event; constructor( @ILogService private readonly _logService: ILogService, @@ -346,6 +348,7 @@ export class AgentHostStateManager extends Disposable { // Without this, evicting a session that still has an active turn // silently strands the active-sessions count above zero forever. if (this._sessionsWithActiveTurn.delete(session)) { + this._onDidChangeSessionActiveTurn.fire({ session, active: false }); this.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootActiveSessionsChanged, activeSessions: this._sessionsWithActiveTurn.size }); } @@ -597,6 +600,7 @@ export class AgentHostStateManager extends Disposable { } else { this._sessionsWithActiveTurn.delete(key); } + this._onDidChangeSessionActiveTurn.fire({ session: key, active: hasActive }); this.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootActiveSessionsChanged, activeSessions: this._sessionsWithActiveTurn.size }); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c8cacadf381e2..41fb6af6d9103 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -37,6 +37,7 @@ import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostGitService } from './agentHostGitService.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { AgentHostChangesetService, IAgentHostChangesetService } from './agentHostChangesetService.js'; +import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js'; import { CHANGESET_DB_METADATA_KEYS, ChangesetSessionCoordinator } from './agentHostChangesetCoordinator.js'; import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; @@ -144,6 +145,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _checkpointService: IAgentHostCheckpointService = NULL_CHECKPOINT_SERVICE, private readonly _rootConfigResource?: URI, private readonly _telemetryService: ITelemetryService = NullTelemetryService, + _fileMonitorService?: IAgentHostFileMonitorService, ) { super(); this._logService.info('AgentService initialized'); @@ -163,11 +165,13 @@ export class AgentService extends Disposable implements IAgentService { // via DI rather than being plumbed plain-class references. const configurationService: IAgentConfigurationService = this._register(new AgentConfigurationService(this._stateManager, this._logService, this._rootConfigResource)); this._configurationService = configurationService; + const fileMonitorService = _fileMonitorService ?? this._register(new AgentHostFileMonitorService(this._fileService, this._logService)); updateAgentHostTelemetryLevelFromConfig(this._telemetryService, this._stateManager.rootState.config?.values); const services = new ServiceCollection( [ILogService, this._logService], [IProductService, this._productService], [IAgentConfigurationService, configurationService], + [IAgentHostFileMonitorService, fileMonitorService], [IAgentHostGitService, this._gitService], [ITelemetryService, this._telemetryService], // The outer agent-host process DI registers `ISessionDataService`, @@ -200,7 +204,8 @@ export class AgentService extends Disposable implements IAgentService { // The coordinator owns all AgentService-side orchestration of the // changeset feature: lifecycle hooks, listSessions overlay, // subscription URI routing, and the deferred-refresh state machine. - this._changesetCoordinator = this._register(new ChangesetSessionCoordinator(this._stateManager, this._changesets, this._configurationService)); + this._changesetCoordinator = this._register(new ChangesetSessionCoordinator(this._stateManager, this._changesets, this._configurationService, fileMonitorService, this._gitService, this._logService)); + this._register(this._stateManager.onDidChangeSessionActiveTurn(e => this._changesetCoordinator.onSessionTurnActiveChanged(e.session, e.active))); this._completions = this._register(instantiationService.createInstance(AgentHostCompletions)); // Built-in generic provider: completes files in the session's workspace folder. diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts new file mode 100644 index 0000000000000..84aeaceb4389a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -0,0 +1,378 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentSession, IAgentSessionMetadata } from '../../common/agentService.js'; +import { buildDefaultChangesetCatalogue, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { buildSubagentSessionUri, SessionStatus, type ISessionFileDiff } from '../../common/state/sessionState.js'; +import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { ChangesetSessionCoordinator, IChangesetSessionMetadata } from '../../node/agentHostChangesetCoordinator.js'; +import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, StaticChangesetKind } from '../../node/agentHostChangesetService.js'; +import { IAgentHostFileMonitorOptions, IAgentHostFileMonitorService } from '../../node/agentHostFileMonitorService.js'; +import { IAgentHostGitService } from '../../node/agentHostGitService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { createNoopGitService } from '../common/sessionTestHelpers.js'; + +suite('ChangesetSessionCoordinator', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createSession(stateManager: AgentHostStateManager, session: string, workingDirectory?: string, emitNotification = true): void { + stateManager.createSession({ + resource: session, + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, + workingDirectory, + changesets: buildDefaultChangesetCatalogue(session), + }, { emitNotification }); + stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); + } + + function createEnvironment(root: URI = URI.file('/repo')): { + stateManager: AgentHostStateManager; + changesets: TestChangesetService; + monitor: TestFileMonitorService; + gitService: IAgentHostGitService & { readonly rootLookupCalls: string[]; waitForRootLookups(count: number): Promise }; + coordinator: ChangesetSessionCoordinator; + } { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, new NullLogService())); + const changesets = new TestChangesetService(); + const monitor = disposables.add(new TestFileMonitorService()); + const gitService = createGitService(root); + const coordinator = disposables.add(new ChangesetSessionCoordinator(stateManager, changesets, configurationService, monitor, gitService, new NullLogService())); + return { stateManager, changesets, monitor, gitService, coordinator }; + } + + test('shares root watchers across sessions and fans out root changes to static refreshes', async () => { + const firstSession = AgentSession.uri('mock', 'session-1').toString(); + const secondSession = AgentSession.uri('mock', 'session-2').toString(); + const root = URI.file('/repo'); + const environment = createEnvironment(root); + createSession(environment.stateManager, firstSession, 'file:///repo/worktree-a'); + createSession(environment.stateManager, secondSession, 'file:///repo/worktree-b'); + + environment.coordinator.onFirstSubscriber(URI.parse(firstSession)); + await environment.monitor.waitForAcquisitions(1); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(secondSession))); + await environment.gitService.waitForRootLookups(2); + await tick(); + environment.changesets.clearRefreshes(); + + environment.monitor.fire(root); + await tick(); + + assert.deepStrictEqual({ + acquisitions: environment.monitor.acquisitions, + uncommittedRefreshes: environment.changesets.uncommittedRefreshes, + sessionRefreshes: environment.changesets.sessionRefreshes, + }, { + acquisitions: ['file:///repo'], + uncommittedRefreshes: [firstSession, secondSession], + sessionRefreshes: [firstSession, secondSession], + }); + }); + + test('releases a root watcher after the last interested session unsubscribes', async () => { + const firstSession = AgentSession.uri('mock', 'session-1').toString(); + const secondSession = AgentSession.uri('mock', 'session-2').toString(); + const environment = createEnvironment(); + createSession(environment.stateManager, firstSession, 'file:///repo/worktree-a'); + createSession(environment.stateManager, secondSession, 'file:///repo/worktree-b'); + + environment.coordinator.onFirstSubscriber(URI.parse(firstSession)); + await environment.monitor.waitForAcquisitions(1); + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(secondSession))); + await environment.gitService.waitForRootLookups(2); + await tick(); + + environment.coordinator.onLastSubscriber(URI.parse(firstSession)); + assert.deepStrictEqual(environment.monitor.disposals, []); + environment.coordinator.onLastSubscriber(URI.parse(buildUncommittedChangesetUri(secondSession))); + assert.deepStrictEqual(environment.monitor.disposals, ['file:///repo']); + }); + + test('attaches deferred watch interest on materialization without re-querying an unchanged root', async () => { + const session = AgentSession.uri('mock', 'session-1').toString(); + const environment = createEnvironment(); + createSession(environment.stateManager, session, undefined, false); + + environment.coordinator.onFirstSubscriber(URI.parse(buildUncommittedChangesetUri(session))); + await tick(); + assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, rootLookups: environment.gitService.rootLookupCalls }, { acquisitions: [], rootLookups: [] }); + + const summary = environment.stateManager.getSessionState(session)!.summary; + environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectory: 'file:///repo/worktree' }); + environment.coordinator.onSessionMaterialized(session); + await environment.monitor.waitForAcquisitions(1); + + environment.coordinator.onSessionMaterialized(session); + await tick(); + + assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, rootLookups: environment.gitService.rootLookupCalls }, { + acquisitions: ['file:///repo'], + rootLookups: ['file:///repo/worktree'], + }); + }); + + test('does not attach root state when watcher acquisition fails', async () => { + const session = AgentSession.uri('mock', 'session-1').toString(); + const environment = createEnvironment(); + createSession(environment.stateManager, session, 'file:///repo/worktree'); + + environment.monitor.failAcquire = true; + environment.coordinator.onFirstSubscriber(URI.parse(session)); + await environment.gitService.waitForRootLookups(1); + await tick(); + environment.monitor.fire(URI.file('/repo')); + await tick(); + + assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, refreshes: environment.changesets.uncommittedRefreshes }, { + acquisitions: ['file:///repo'], + refreshes: [session], + }); + }); + + test('active turn suspends and resumes root watcher when interest remains', async () => { + const session = AgentSession.uri('mock', 'session-1').toString(); + const root = URI.file('/repo'); + const environment = createEnvironment(root); + createSession(environment.stateManager, session, 'file:///repo/worktree'); + + environment.coordinator.onFirstSubscriber(URI.parse(session)); + await environment.monitor.waitForAcquisitions(1); + environment.coordinator.onSessionTurnActiveChanged(session, true); + await environment.gitService.waitForRootLookups(2); + await tick(); + environment.changesets.clearRefreshes(); + environment.monitor.fire(root); + await tick(); + + environment.coordinator.onSessionTurnActiveChanged(session, false); + await environment.monitor.waitForAcquisitions(2); + environment.monitor.fire(root); + await tick(); + + assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, disposals: environment.monitor.disposals, refreshes: environment.changesets.uncommittedRefreshes }, { + acquisitions: ['file:///repo', 'file:///repo'], + disposals: ['file:///repo'], + refreshes: [session], + }); + }); + + test('active session sharing a root suspends watcher for other subscribed sessions', async () => { + const firstSession = AgentSession.uri('mock', 'session-1').toString(); + const secondSession = AgentSession.uri('mock', 'session-2').toString(); + const root = URI.file('/repo'); + const environment = createEnvironment(root); + createSession(environment.stateManager, firstSession, 'file:///repo/worktree-a'); + createSession(environment.stateManager, secondSession, 'file:///repo/worktree-b'); + + environment.coordinator.onFirstSubscriber(URI.parse(firstSession)); + await environment.monitor.waitForAcquisitions(1); + environment.coordinator.onFirstSubscriber(URI.parse(secondSession)); + await environment.gitService.waitForRootLookups(2); + await tick(); + environment.coordinator.onSessionTurnActiveChanged(secondSession, true); + await environment.gitService.waitForRootLookups(3); + await tick(); + environment.changesets.clearRefreshes(); + environment.monitor.fire(root); + await tick(); + + environment.coordinator.onSessionTurnActiveChanged(secondSession, false); + await environment.monitor.waitForAcquisitions(2); + environment.monitor.fire(root); + await tick(); + + assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, disposals: environment.monitor.disposals, uncommittedRefreshes: environment.changesets.uncommittedRefreshes }, { + acquisitions: ['file:///repo', 'file:///repo'], + disposals: ['file:///repo'], + uncommittedRefreshes: [firstSession, secondSession], + }); + }); + + test('active subagent maps to parent root and suspends watcher until subagent completes', async () => { + const parentSession = AgentSession.uri('mock', 'session-1').toString(); + const subagentSession = buildSubagentSessionUri(parentSession, 'tool-1'); + const root = URI.file('/repo'); + const environment = createEnvironment(root); + createSession(environment.stateManager, parentSession, 'file:///repo/worktree'); + createSession(environment.stateManager, subagentSession, undefined); + + environment.coordinator.onFirstSubscriber(URI.parse(parentSession)); + await environment.monitor.waitForAcquisitions(1); + environment.coordinator.onSessionTurnActiveChanged(subagentSession, true); + await environment.gitService.waitForRootLookups(2); + await tick(); + environment.changesets.clearRefreshes(); + environment.monitor.fire(root); + await tick(); + + environment.coordinator.onSessionTurnActiveChanged(subagentSession, false); + await environment.monitor.waitForAcquisitions(2); + environment.monitor.fire(root); + await tick(); + + assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, disposals: environment.monitor.disposals, refreshes: environment.changesets.uncommittedRefreshes }, { + acquisitions: ['file:///repo', 'file:///repo'], + disposals: ['file:///repo'], + refreshes: [parentSession], + }); + }); + + test('turn ending after unsubscribe or dispose does not reattach watcher', async () => { + const session = AgentSession.uri('mock', 'session-1').toString(); + const environment = createEnvironment(); + createSession(environment.stateManager, session, 'file:///repo/worktree'); + + environment.coordinator.onFirstSubscriber(URI.parse(session)); + await environment.monitor.waitForAcquisitions(1); + environment.coordinator.onSessionTurnActiveChanged(session, true); + await environment.gitService.waitForRootLookups(2); + await tick(); + environment.coordinator.onLastSubscriber(URI.parse(session)); + environment.coordinator.onSessionDisposed(session); + environment.coordinator.onSessionTurnActiveChanged(session, false); + await tick(); + + assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, disposals: environment.monitor.disposals }, { + acquisitions: ['file:///repo'], + disposals: ['file:///repo'], + }); + }); +}); + +function createGitService(root: URI): IAgentHostGitService & { readonly rootLookupCalls: string[]; waitForRootLookups(count: number): Promise } { + const rootLookupCalls: string[] = []; + const waiters: Array<{ count: number; deferred: DeferredPromise }> = []; + const releaseWaiters = () => { + for (const waiter of [...waiters]) { + if (rootLookupCalls.length >= waiter.count) { + waiters.splice(waiters.indexOf(waiter), 1); + void waiter.deferred.complete(undefined); + } + } + }; + return { + ...createNoopGitService(), + rootLookupCalls, + async getRepositoryRoot(workingDirectory: URI): Promise { + rootLookupCalls.push(workingDirectory.toString()); + releaseWaiters(); + return root; + }, + waitForRootLookups(count: number): Promise { + if (rootLookupCalls.length >= count) { + return Promise.resolve(); + } + const deferred = new DeferredPromise(); + waiters.push({ count, deferred }); + return deferred.p; + }, + }; +} + +class TestFileMonitorService extends Disposable implements IAgentHostFileMonitorService { + declare readonly _serviceBrand: undefined; + + readonly acquisitions: string[] = []; + readonly disposals: string[] = []; + failAcquire = false; + private readonly _callbacks = new Map void>>(); + private readonly _acquisitionWaiters: Array<{ count: number; deferred: DeferredPromise }> = []; + + acquire(folder: URI, callback: () => void, _options?: IAgentHostFileMonitorOptions): IDisposable | undefined { + const root = folder.toString(); + this.acquisitions.push(root); + if (this.failAcquire) { + this._releaseAcquisitionWaiters(); + return undefined; + } + let callbacks = this._callbacks.get(root); + if (!callbacks) { + callbacks = new Set<() => void>(); + this._callbacks.set(root, callbacks); + } + callbacks.add(callback); + this._releaseAcquisitionWaiters(); + return toDisposable(() => { + callbacks.delete(callback); + this.disposals.push(root); + }); + } + + fire(root: URI): void { + for (const callback of this._callbacks.get(root.toString()) ?? []) { + callback(); + } + } + + waitForAcquisitions(count: number): Promise { + if (this.acquisitions.length >= count) { + return Promise.resolve(); + } + const deferred = new DeferredPromise(); + this._acquisitionWaiters.push({ count, deferred }); + return deferred.p; + } + + private _releaseAcquisitionWaiters(): void { + for (const waiter of [...this._acquisitionWaiters]) { + if (this.acquisitions.length >= waiter.count) { + this._acquisitionWaiters.splice(this._acquisitionWaiters.indexOf(waiter), 1); + void waiter.deferred.complete(undefined); + } + } + } +} + +class TestChangesetService implements IAgentHostChangesetService { + declare readonly _serviceBrand: undefined; + + readonly uncommittedRefreshes: string[] = []; + readonly sessionRefreshes: string[] = []; + + registerStaticChangesets(_session: string): void { } + restoreStaticChangeset(_session: string, _kind: StaticChangesetKind, _diffs: readonly ISessionFileDiff[]): void { } + parsePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } + applyPersistedStaticChangesets(_sessionUri: string, _diffs: IRestoredChangesetDiffs): void { } + restorePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } + isStaticChangesetComputeActive(_changesetUri: string): boolean { return false; } + refreshUncommittedChangeset(session: string): void { + this.uncommittedRefreshes.push(session); + } + refreshSessionChangeset(session: string): void { + this.sessionRefreshes.push(session); + } + async computeTurnChangeset(session: string, turnId: string): Promise { return `${session}/changeset/turn/${turnId}`; } + async computeCompareTurnsChangeset(session: string, originalTurnId: string, modifiedTurnId: string): Promise { return `${session}/changeset/compare/${originalTurnId}/${modifiedTurnId}`; } + onToolCallEditsApplied(_session: string, _turnId: string): void { } + onTurnComplete(_session: string, _turnId: string | undefined): void { } + onSessionTruncated(_session: string): void { } + setTurnSubscriberProbe(_probe: (session: string, turnId: string) => boolean): void { } + + clearRefreshes(): void { + this.uncommittedRefreshes.length = 0; + this.sessionRefreshes.length = 0; + } + + getListMetadataKeys(_sessionStr: string): Record | undefined { return undefined; } + decorateListEntry(entry: IAgentSessionMetadata, _metadata: IChangesetSessionMetadata): IAgentSessionMetadata { return entry; } +} + +function tick(): Promise { + return new Promise(resolve => setImmediate(resolve)); +} diff --git a/src/vs/platform/agentHost/test/node/agentHostFileMonitorService.test.ts b/src/vs/platform/agentHost/test/node/agentHostFileMonitorService.test.ts new file mode 100644 index 0000000000000..f7f61a883479b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostFileMonitorService.test.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileChangesEvent, FileChangeType, IFileService } from '../../../files/common/files.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentHostFileMonitorService } from '../../node/agentHostFileMonitorService.js'; + +suite('AgentHostFileMonitorService', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('shares one recursive watcher per folder/options and refcounts callbacks', () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const fileService = new TestFileService(); + const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); + const folder = URI.file('/repo'); + let first = 0; + let second = 0; + + const firstRegistration = monitor.acquire(folder, () => first++, { debounceMs: 10 }); + const secondRegistration = monitor.acquire(folder, () => second++, { debounceMs: 10 }); + assert.deepStrictEqual(fileService.snapshot(), { watches: 1, disposed: 0 }); + + fileService.fire(URI.file('/repo/src/a.ts')); + await timeout(11); + assert.deepStrictEqual({ first, second }, { first: 1, second: 1 }); + + firstRegistration.dispose(); + fileService.fire(URI.file('/repo/src/b.ts')); + await timeout(11); + assert.deepStrictEqual({ first, second, snapshot: fileService.snapshot() }, { first: 1, second: 2, snapshot: { watches: 1, disposed: 0 } }); + + secondRegistration.dispose(); + assert.deepStrictEqual(fileService.snapshot(), { watches: 1, disposed: 1 }); + }); + }); + + test('filters known repository metadata noise before debouncing', () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const fileService = new TestFileService(); + const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); + let calls = 0; + + disposables.add(monitor.acquire(URI.file('/repo'), () => calls++, { debounceMs: 10 })); + fileService.fire(URI.file('/repo/.git/objects/12/abcdef')); + fileService.fire(URI.file('/repo/.git/index.lock')); + fileService.fire(URI.file('/repo/.watchman-cookie-123')); + await timeout(11); + assert.strictEqual(calls, 0); + + fileService.fire(URI.file('/repo/src/a.ts')); + await timeout(11); + assert.strictEqual(calls, 1); + }); + }); + + test('filters custom excludes before debouncing', () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const fileService = new TestFileService(); + const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); + let calls = 0; + + disposables.add(monitor.acquire(URI.file('/repo'), () => calls++, { excludes: ['**/generated/**'], debounceMs: 10 })); + fileService.fire(URI.file('/repo/generated/a.ts')); + await timeout(11); + assert.strictEqual(calls, 0); + + fileService.fire(URI.file('/repo/src/a.ts')); + await timeout(11); + assert.strictEqual(calls, 1); + }); + }); + + test('sorts excludes when sharing watchers', () => { + const fileService = new TestFileService(); + const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); + const folder = URI.file('/repo'); + + disposables.add(monitor.acquire(folder, () => { }, { excludes: ['**/b/**', '**/a/**'], debounceMs: 10 })); + disposables.add(monitor.acquire(folder, () => { }, { excludes: ['**/a/**', '**/b/**'], debounceMs: 10 })); + + assert.deepStrictEqual(fileService.snapshot(), { watches: 1, disposed: 0 }); + }); + + test('canonicalizes equivalent folder keys when sharing watchers', () => { + const fileService = new TestFileService(); + const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); + + disposables.add(monitor.acquire(URI.file('/repo'), () => { }, { debounceMs: 10 })); + disposables.add(monitor.acquire(URI.file('/repo/../repo/'), () => { }, { debounceMs: 10 })); + + assert.deepStrictEqual(fileService.snapshot(), { watches: 1, disposed: 0 }); + }); + + test('returns undefined when watcher acquisition fails', () => { + const fileService = new TestFileService(); + fileService.failWatch = true; + const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); + + const registration = monitor.acquire(URI.file('/repo'), () => { }, { debounceMs: 10 }); + + assert.deepStrictEqual({ registration, snapshot: fileService.snapshot() }, { registration: undefined, snapshot: { watches: 1, disposed: 0 } }); + }); + + test('uses one file-change listener across monitor entries', () => { + const fileService = new TestFileService(); + const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); + + disposables.add(monitor.acquire(URI.file('/repo-a'), () => { }, { debounceMs: 10 })); + disposables.add(monitor.acquire(URI.file('/repo-b'), () => { }, { debounceMs: 10 })); + + assert.deepStrictEqual({ snapshot: fileService.snapshot(), listeners: fileService.fileChangeListenerCount }, { + snapshot: { watches: 2, disposed: 0 }, + listeners: 1, + }); + }); + + test('disposing service cleans up active watchers and pending debounce callbacks', () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const fileService = new TestFileService(); + const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); + let calls = 0; + + const registration = monitor.acquire(URI.file('/repo'), () => calls++, { debounceMs: 10 }); + fileService.fire(URI.file('/repo/src/a.ts')); + monitor.dispose(); + registration.dispose(); + await timeout(11); + + fileService.fire(URI.file('/repo/src/b.ts')); + await timeout(11); + assert.deepStrictEqual({ calls, snapshot: fileService.snapshot() }, { calls: 0, snapshot: { watches: 1, disposed: 1 } }); + }); + }); +}); + +class TestFileService { + private readonly _onDidFilesChange = new Emitter(); + private readonly _onDidWatchError = new Emitter(); + private _watchCount = 0; + private _disposeCount = 0; + private _fileChangeListenerCount = 0; + failWatch = false; + + private readonly _onDidFilesChangeEvent: Event = (listener, thisArgs, disposables) => { + this._fileChangeListenerCount++; + return this._onDidFilesChange.event(listener, thisArgs, disposables); + }; + + readonly service = { + _serviceBrand: undefined, + onDidChangeFileSystemProviderRegistrations: Event.None, + onDidChangeFileSystemProviderCapabilities: Event.None, + onWillActivateFileSystemProvider: Event.None, + onDidFilesChange: this._onDidFilesChangeEvent, + onDidWatchError: this._onDidWatchError.event, + watch: (_resource: URI, _options?: Parameters[1]): IDisposable => { + this._watchCount++; + if (this.failWatch) { + throw new Error('watch failed'); + } + return toDisposable(() => this._disposeCount++); + }, + dispose: () => { }, + } as Partial as IFileService; + + fire(resource: URI, type: FileChangeType = FileChangeType.UPDATED): void { + this._onDidFilesChange.fire(new FileChangesEvent([{ resource, type }], false)); + } + + snapshot(): { watches: number; disposed: number } { + return { watches: this._watchCount, disposed: this._disposeCount }; + } + + get fileChangeListenerCount(): number { + return this._fileChangeListenerCount; + } +} diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 06d2602bbc5a0..4fc6d41c69e79 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -393,6 +393,66 @@ suite('AgentHostStateManager', () => { assert.strictEqual(manager.hasActiveSessions, false); }); + test('active turn event follows reducer-derived active state transitions', () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); + const events: Array<{ session: string; active: boolean }> = []; + disposables.add(manager.onDidChangeSessionActiveTurn(e => events.push(e))); + + manager.dispatchServerAction(sessionUri, { + type: ActionType.SessionTurnStarted, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + manager.dispatchServerAction(sessionUri, { + type: ActionType.SessionTurnComplete, + turnId: 'stale-turn', + }); + manager.dispatchServerAction(sessionUri, { + type: ActionType.SessionError, + turnId: 'turn-1', + error: { errorType: 'failed', message: 'boom' }, + }); + + assert.deepStrictEqual(events, [ + { session: sessionUri, active: true }, + { session: sessionUri, active: false }, + ]); + }); + + test('active turn event covers cancellation and removal while active', () => { + const session2Uri = URI.from({ scheme: 'copilot', path: '/test-session-2' }).toString(); + manager.createSession(makeSessionSummary(sessionUri)); + manager.createSession(makeSessionSummary(session2Uri)); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); + manager.dispatchServerAction(session2Uri, { type: ActionType.SessionReady, }); + const events: Array<{ session: string; active: boolean }> = []; + disposables.add(manager.onDidChangeSessionActiveTurn(e => events.push(e))); + + manager.dispatchServerAction(sessionUri, { + type: ActionType.SessionTurnStarted, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + manager.dispatchServerAction(sessionUri, { + type: ActionType.SessionTurnCancelled, + turnId: 'turn-1', + }); + manager.dispatchServerAction(session2Uri, { + type: ActionType.SessionTurnStarted, + turnId: 'turn-2', + userMessage: { text: 'hi' }, + }); + manager.removeSession(session2Uri); + + assert.deepStrictEqual(events, [ + { session: sessionUri, active: true }, + { session: sessionUri, active: false }, + { session: session2Uri, active: true }, + { session: session2Uri, active: false }, + ]); + }); + test('restoreSession creates session in Ready state with pre-populated turns', () => { const turns = [ { From ffd41a257d6dd687962aa0a86abcf2f0cf1532d0 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 26 May 2026 13:49:33 +1000 Subject: [PATCH 2/4] refactor: encapsulate file monitor acquisition in a dedicated function --- .../node/agentHostFileMonitorService.test.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/agentHostFileMonitorService.test.ts b/src/vs/platform/agentHost/test/node/agentHostFileMonitorService.test.ts index f7f61a883479b..be4edf9125519 100644 --- a/src/vs/platform/agentHost/test/node/agentHostFileMonitorService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostFileMonitorService.test.ts @@ -21,6 +21,12 @@ suite('AgentHostFileMonitorService', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); + function acquire(monitor: AgentHostFileMonitorService, folder: URI, callback: () => void, options?: { readonly excludes?: readonly string[]; readonly debounceMs?: number }): IDisposable { + const registration = monitor.acquire(folder, callback, options); + assert.ok(registration, 'expected file monitor acquisition to succeed'); + return registration; + } + test('shares one recursive watcher per folder/options and refcounts callbacks', () => { return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { const fileService = new TestFileService(); @@ -29,8 +35,8 @@ suite('AgentHostFileMonitorService', () => { let first = 0; let second = 0; - const firstRegistration = monitor.acquire(folder, () => first++, { debounceMs: 10 }); - const secondRegistration = monitor.acquire(folder, () => second++, { debounceMs: 10 }); + const firstRegistration = acquire(monitor, folder, () => first++, { debounceMs: 10 }); + const secondRegistration = acquire(monitor, folder, () => second++, { debounceMs: 10 }); assert.deepStrictEqual(fileService.snapshot(), { watches: 1, disposed: 0 }); fileService.fire(URI.file('/repo/src/a.ts')); @@ -53,7 +59,7 @@ suite('AgentHostFileMonitorService', () => { const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); let calls = 0; - disposables.add(monitor.acquire(URI.file('/repo'), () => calls++, { debounceMs: 10 })); + disposables.add(acquire(monitor, URI.file('/repo'), () => calls++, { debounceMs: 10 })); fileService.fire(URI.file('/repo/.git/objects/12/abcdef')); fileService.fire(URI.file('/repo/.git/index.lock')); fileService.fire(URI.file('/repo/.watchman-cookie-123')); @@ -72,7 +78,7 @@ suite('AgentHostFileMonitorService', () => { const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); let calls = 0; - disposables.add(monitor.acquire(URI.file('/repo'), () => calls++, { excludes: ['**/generated/**'], debounceMs: 10 })); + disposables.add(acquire(monitor, URI.file('/repo'), () => calls++, { excludes: ['**/generated/**'], debounceMs: 10 })); fileService.fire(URI.file('/repo/generated/a.ts')); await timeout(11); assert.strictEqual(calls, 0); @@ -88,8 +94,8 @@ suite('AgentHostFileMonitorService', () => { const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); const folder = URI.file('/repo'); - disposables.add(monitor.acquire(folder, () => { }, { excludes: ['**/b/**', '**/a/**'], debounceMs: 10 })); - disposables.add(monitor.acquire(folder, () => { }, { excludes: ['**/a/**', '**/b/**'], debounceMs: 10 })); + disposables.add(acquire(monitor, folder, () => { }, { excludes: ['**/b/**', '**/a/**'], debounceMs: 10 })); + disposables.add(acquire(monitor, folder, () => { }, { excludes: ['**/a/**', '**/b/**'], debounceMs: 10 })); assert.deepStrictEqual(fileService.snapshot(), { watches: 1, disposed: 0 }); }); @@ -98,8 +104,8 @@ suite('AgentHostFileMonitorService', () => { const fileService = new TestFileService(); const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); - disposables.add(monitor.acquire(URI.file('/repo'), () => { }, { debounceMs: 10 })); - disposables.add(monitor.acquire(URI.file('/repo/../repo/'), () => { }, { debounceMs: 10 })); + disposables.add(acquire(monitor, URI.file('/repo'), () => { }, { debounceMs: 10 })); + disposables.add(acquire(monitor, URI.file('/repo/../repo/'), () => { }, { debounceMs: 10 })); assert.deepStrictEqual(fileService.snapshot(), { watches: 1, disposed: 0 }); }); @@ -118,8 +124,8 @@ suite('AgentHostFileMonitorService', () => { const fileService = new TestFileService(); const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); - disposables.add(monitor.acquire(URI.file('/repo-a'), () => { }, { debounceMs: 10 })); - disposables.add(monitor.acquire(URI.file('/repo-b'), () => { }, { debounceMs: 10 })); + disposables.add(acquire(monitor, URI.file('/repo-a'), () => { }, { debounceMs: 10 })); + disposables.add(acquire(monitor, URI.file('/repo-b'), () => { }, { debounceMs: 10 })); assert.deepStrictEqual({ snapshot: fileService.snapshot(), listeners: fileService.fileChangeListenerCount }, { snapshot: { watches: 2, disposed: 0 }, @@ -133,7 +139,7 @@ suite('AgentHostFileMonitorService', () => { const monitor = disposables.add(new AgentHostFileMonitorService(fileService.service, new NullLogService())); let calls = 0; - const registration = monitor.acquire(URI.file('/repo'), () => calls++, { debounceMs: 10 }); + const registration = acquire(monitor, URI.file('/repo'), () => calls++, { debounceMs: 10 }); fileService.fire(URI.file('/repo/src/a.ts')); monitor.dispose(); registration.dispose(); From 4fa416369cda6a11bc7e6848e6c4206ccce109dd Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 26 May 2026 14:03:36 +1000 Subject: [PATCH 3/4] docs: enhance documentation for ChangesetFileMonitorCoordinator lifecycle and monitoring behavior --- .../node/agentHostChangesetFileMonitorCoordinator.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts index d383b8adfecb8..a314094d1d8bb 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts @@ -41,6 +41,13 @@ class WatchInterestReferenceCollection extends ReferenceCollection { * debounce mechanics; this coordinator owns the changeset-specific lifecycle: * subscription interest, session materialization, repository-root resolution, * root-level watcher sharing, and refresh fanout. + * + * We only monitor roots while at least one client is subscribed to a session or + * static changeset that needs fresh changeset counts. We do not monitor while a + * session on that root is actively running a turn: agent/tool edits made during + * the turn are captured by the turn lifecycle, and the static changesets are + * recomputed once when the turn completes. Watching during the turn would add + * duplicate file-system noise without improving correctness. */ export class ChangesetFileMonitorCoordinator extends Disposable { From 4205981d45e03dd8202a6f453171afa88fb36bc2 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Tue, 26 May 2026 14:15:10 +1000 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/node/agentHostFileMonitorService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts b/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts index b7637dc587822..9a475d7e3c13a 100644 --- a/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts +++ b/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts @@ -100,7 +100,7 @@ export class AgentHostFileMonitorService extends Disposable implements IAgentHos }); } - private _createEntry(key: string, folder: URI, excludes: readonly string[], debounceMs: number): IMonitorEntry { + private _createEntry(_key: string, folder: URI, excludes: readonly string[], debounceMs: number): IMonitorEntry { const disposable = new DisposableStore(); try { const debounce = disposable.add(new MutableDisposable());