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);