From 8c433560907f16ed665bb95072a447516cc9ca78 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Apr 2026 18:04:29 +0200 Subject: [PATCH] Close stale VS Code worktree repos VS Code keeps SCM repositories open after Guardex deletes a worktree if the active-session record disappears in the same cleanup pass. The companion now remembers observed worktree paths and sweeps deleted managed workspace folders so refresh can issue git.close even when session discovery no longer returns the deleted sandbox. Constraint: VS Code repo-scan ignores prevent future discovery but do not close already-open repositories. Rejected: Rely on git.repositoryScanIgnoredFolders alone | it cannot remove existing SCM providers. Confidence: high Scope-risk: narrow Directive: Keep the active-agents extension and template copy byte-for-byte aligned. Tested: node --test test/vscode-active-agents-session-state.test.js Tested: node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js test/setup.test.js Not-tested: Manual VS Code window reload behavior after extension update. --- .../vscode/guardex-active-agents/extension.js | 46 +++++++++- ...vscode-active-agents-session-state.test.js | 86 +++++++++++++++++++ vscode/guardex-active-agents/extension.js | 46 +++++++++- 3 files changed, 176 insertions(+), 2 deletions(-) diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index d7fb051..f149d77 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -2399,6 +2399,19 @@ function normalizeAbsolutePath(value) { return typeof value === 'string' && value.trim() ? path.resolve(value) : ''; } +function isManagedWorktreePath(worktreePath) { + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (!normalizedWorktreePath) { + return false; + } + + return MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => { + const normalizedRelativeRoot = path.normalize(relativeRoot); + const marker = `${path.sep}${normalizedRelativeRoot}${path.sep}`; + return normalizedWorktreePath.includes(marker); + }); +} + function removeDeletedWorktreeWorkspaceFolder(worktreePath) { if (typeof vscode.workspace.updateWorkspaceFolders !== 'function') { return false; @@ -2440,6 +2453,16 @@ async function closeDeletedWorktreeRepository(worktreePath) { return true; } +function findDeletedManagedWorkspaceFolders() { + return (vscode.workspace.workspaceFolders || []) + .map((folder) => normalizeAbsolutePath(folder?.uri?.fsPath)) + .filter((workspacePath) => ( + workspacePath + && !fs.existsSync(workspacePath) + && isManagedWorktreePath(workspacePath) + )); +} + function localizeChangeForSession(session, change) { if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { return null; @@ -3480,6 +3503,7 @@ class ActiveAgentsRefreshController { this.refreshTimer = null; this.sessionWatchers = new Map(); this.closedMissingWorktreeRepositories = new Set(); + this.observedWorktreePaths = new Set(); } scheduleRefresh() { @@ -3502,6 +3526,10 @@ class ActiveAgentsRefreshController { const repoEntries = await findRepoSessionEntries(); const liveSessionKeys = new Set(); + for (const workspacePath of findDeletedManagedWorkspaceFolders()) { + await this.closeMissingWorktreeRepository(workspacePath); + } + for (const entry of repoEntries) { for (const session of entry.sessions) { const worktreePath = sessionWorktreePath(session); @@ -3512,6 +3540,7 @@ class ActiveAgentsRefreshController { } if (normalizedWorktreePath) { this.closedMissingWorktreeRepositories.delete(normalizedWorktreePath); + this.observedWorktreePaths.add(normalizedWorktreePath); } const sessionKey = resolveSessionWatcherKey(session); @@ -3524,15 +3553,30 @@ class ActiveAgentsRefreshController { resolveSessionGitIndexPath(session.worktreePath), ); const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh()); - this.sessionWatchers.set(sessionKey, { watcher, disposables }); + this.sessionWatchers.set(sessionKey, { + watcher, + disposables, + worktreePath: normalizedWorktreePath, + }); } } + for (const observedWorktreePath of this.observedWorktreePaths) { + if (fs.existsSync(observedWorktreePath)) { + this.closedMissingWorktreeRepositories.delete(observedWorktreePath); + continue; + } + await this.closeMissingWorktreeRepository(observedWorktreePath); + } + for (const [sessionKey, entry] of this.sessionWatchers) { if (liveSessionKeys.has(sessionKey)) { continue; } + if (entry.worktreePath && !fs.existsSync(entry.worktreePath)) { + await this.closeMissingWorktreeRepository(entry.worktreePath); + } disposeAll(entry.disposables); entry.watcher.dispose(); this.sessionWatchers.delete(sessionKey); diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 4ba6be3..a5a8a83 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1567,6 +1567,92 @@ test('active-agents extension closes deleted worktree repositories during refres } }); +test('active-agents extension closes deleted worktrees after session records disappear', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-close-deleted-no-session-')); + const worktreePath = path.join(tempRoot, '.omx', 'agent-worktrees', 'deleted-task'); + fs.mkdirSync(worktreePath, { recursive: true }); + const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/deleted-task', + taskName: 'deleted-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + })); + let currentSessionFiles = [{ fsPath: sessionPath }]; + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.workspaceFolders = [ + { uri: { fsPath: tempRoot } }, + { uri: { fsPath: worktreePath } }, + ]; + vscode.workspace.findFiles = async () => currentSessionFiles; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + const gitCloseCalls = () => registrations.executedCommands.filter((entry) => ( + entry.command === 'git.close' + )); + assert.equal(gitCloseCalls().length, 0); + + fs.rmSync(worktreePath, { recursive: true, force: true }); + fs.rmSync(sessionPath, { force: true }); + currentSessionFiles = []; + await registrations.commands.get('gitguardex.activeAgents.refresh')(); + await flushAsyncWork(); + + assert.equal(gitCloseCalls().length, 1); + assert.equal(gitCloseCalls()[0].args[0].fsPath, path.resolve(worktreePath)); + assert.deepEqual(registrations.workspaceFolderUpdates, [ + { start: 1, deleteCount: 1, folders: [] }, + ]); + assert.deepEqual( + vscode.workspace.workspaceFolders.map((folder) => folder.uri.fsPath), + [tempRoot], + ); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension closes deleted managed workspace folders without session state', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-close-stale-folder-')); + const worktreePath = path.join(tempRoot, '.omc', 'agent-worktrees', 'deleted-task'); + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.workspaceFolders = [ + { uri: { fsPath: tempRoot } }, + { uri: { fsPath: worktreePath } }, + ]; + vscode.workspace.findFiles = async () => []; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + await flushAsyncWork(); + + const gitCloseCalls = registrations.executedCommands.filter((entry) => ( + entry.command === 'git.close' + )); + assert.equal(gitCloseCalls.length, 1); + assert.equal(gitCloseCalls[0].args[0].fsPath, path.resolve(worktreePath)); + assert.deepEqual(registrations.workspaceFolderUpdates, [ + { start: 1, deleteCount: 1, folders: [] }, + ]); + assert.deepEqual( + vscode.workspace.workspaceFolders.map((folder) => folder.uri.fsPath), + [tempRoot], + ); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents restart command restarts the extension host for this extension only', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-restart-command-')); const { registrations, vscode } = createMockVscode(tempRoot); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index d7fb051..f149d77 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -2399,6 +2399,19 @@ function normalizeAbsolutePath(value) { return typeof value === 'string' && value.trim() ? path.resolve(value) : ''; } +function isManagedWorktreePath(worktreePath) { + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (!normalizedWorktreePath) { + return false; + } + + return MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => { + const normalizedRelativeRoot = path.normalize(relativeRoot); + const marker = `${path.sep}${normalizedRelativeRoot}${path.sep}`; + return normalizedWorktreePath.includes(marker); + }); +} + function removeDeletedWorktreeWorkspaceFolder(worktreePath) { if (typeof vscode.workspace.updateWorkspaceFolders !== 'function') { return false; @@ -2440,6 +2453,16 @@ async function closeDeletedWorktreeRepository(worktreePath) { return true; } +function findDeletedManagedWorkspaceFolders() { + return (vscode.workspace.workspaceFolders || []) + .map((folder) => normalizeAbsolutePath(folder?.uri?.fsPath)) + .filter((workspacePath) => ( + workspacePath + && !fs.existsSync(workspacePath) + && isManagedWorktreePath(workspacePath) + )); +} + function localizeChangeForSession(session, change) { if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { return null; @@ -3480,6 +3503,7 @@ class ActiveAgentsRefreshController { this.refreshTimer = null; this.sessionWatchers = new Map(); this.closedMissingWorktreeRepositories = new Set(); + this.observedWorktreePaths = new Set(); } scheduleRefresh() { @@ -3502,6 +3526,10 @@ class ActiveAgentsRefreshController { const repoEntries = await findRepoSessionEntries(); const liveSessionKeys = new Set(); + for (const workspacePath of findDeletedManagedWorkspaceFolders()) { + await this.closeMissingWorktreeRepository(workspacePath); + } + for (const entry of repoEntries) { for (const session of entry.sessions) { const worktreePath = sessionWorktreePath(session); @@ -3512,6 +3540,7 @@ class ActiveAgentsRefreshController { } if (normalizedWorktreePath) { this.closedMissingWorktreeRepositories.delete(normalizedWorktreePath); + this.observedWorktreePaths.add(normalizedWorktreePath); } const sessionKey = resolveSessionWatcherKey(session); @@ -3524,15 +3553,30 @@ class ActiveAgentsRefreshController { resolveSessionGitIndexPath(session.worktreePath), ); const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh()); - this.sessionWatchers.set(sessionKey, { watcher, disposables }); + this.sessionWatchers.set(sessionKey, { + watcher, + disposables, + worktreePath: normalizedWorktreePath, + }); } } + for (const observedWorktreePath of this.observedWorktreePaths) { + if (fs.existsSync(observedWorktreePath)) { + this.closedMissingWorktreeRepositories.delete(observedWorktreePath); + continue; + } + await this.closeMissingWorktreeRepository(observedWorktreePath); + } + for (const [sessionKey, entry] of this.sessionWatchers) { if (liveSessionKeys.has(sessionKey)) { continue; } + if (entry.worktreePath && !fs.existsSync(entry.worktreePath)) { + await this.closeMissingWorktreeRepository(entry.worktreePath); + } disposeAll(entry.disposables); entry.watcher.dispose(); this.sessionWatchers.delete(sessionKey);