diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index 104ef8f..933e21a 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -508,7 +508,7 @@ is_local_branch_delete_error() { is_remote_branch_missing_error() { local output="$1" - if [[ "$output" == *"remote ref does not exist"* ]] || [[ "$output" == *"failed to push some refs"* ]]; then + if [[ "$output" == *"remote ref does not exist"* ]]; then return 0 fi return 1 @@ -893,8 +893,8 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then if is_remote_branch_missing_error "$remote_delete_output"; then echo "[agent-branch-finish] Remote branch '${SOURCE_BRANCH}' was already deleted; continuing cleanup." >&2 else + echo "[agent-branch-finish] Warning: remote branch cleanup failed for '${SOURCE_BRANCH}' after merge; continuing local cleanup." >&2 echo "$remote_delete_output" >&2 - exit 1 fi fi fi diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index d96ae0b..d7fb051 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -2395,6 +2395,51 @@ function isPathWithin(parentPath, targetPath) { return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); } +function normalizeAbsolutePath(value) { + return typeof value === 'string' && value.trim() ? path.resolve(value) : ''; +} + +function removeDeletedWorktreeWorkspaceFolder(worktreePath) { + if (typeof vscode.workspace.updateWorkspaceFolders !== 'function') { + return false; + } + + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (!normalizedWorktreePath) { + return false; + } + + const workspaceFolders = vscode.workspace.workspaceFolders || []; + const folderIndex = workspaceFolders.findIndex((folder) => ( + normalizeAbsolutePath(folder?.uri?.fsPath) === normalizedWorktreePath + )); + if (folderIndex < 0) { + return false; + } + + try { + return vscode.workspace.updateWorkspaceFolders(folderIndex, 1) === true; + } catch (_error) { + return false; + } +} + +async function closeDeletedWorktreeRepository(worktreePath) { + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (!normalizedWorktreePath || fs.existsSync(normalizedWorktreePath)) { + return false; + } + + try { + await vscode.commands.executeCommand('git.close', vscode.Uri.file(normalizedWorktreePath)); + } catch (_error) { + // The Git extension may have already removed this repository. + } + + removeDeletedWorktreeWorkspaceFolder(normalizedWorktreePath); + return true; +} + function localizeChangeForSession(session, change) { if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { return null; @@ -3434,6 +3479,7 @@ class ActiveAgentsRefreshController { this.inspectPanelManager = inspectPanelManager; this.refreshTimer = null; this.sessionWatchers = new Map(); + this.closedMissingWorktreeRepositories = new Set(); } scheduleRefresh() { @@ -3458,6 +3504,16 @@ class ActiveAgentsRefreshController { for (const entry of repoEntries) { for (const session of entry.sessions) { + const worktreePath = sessionWorktreePath(session); + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (normalizedWorktreePath && !fs.existsSync(normalizedWorktreePath)) { + await this.closeMissingWorktreeRepository(normalizedWorktreePath); + continue; + } + if (normalizedWorktreePath) { + this.closedMissingWorktreeRepositories.delete(normalizedWorktreePath); + } + const sessionKey = resolveSessionWatcherKey(session); liveSessionKeys.add(sessionKey); if (this.sessionWatchers.has(sessionKey)) { @@ -3483,6 +3539,16 @@ class ActiveAgentsRefreshController { } } + async closeMissingWorktreeRepository(worktreePath) { + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (!normalizedWorktreePath || this.closedMissingWorktreeRepositories.has(normalizedWorktreePath)) { + return; + } + + this.closedMissingWorktreeRepositories.add(normalizedWorktreePath); + await closeDeletedWorktreeRepository(normalizedWorktreePath); + } + dispose() { if (this.refreshTimer) { clearTimeout(this.refreshTimer); diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 8c009a2..c725ca6 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", "publisher": "Recodee", - "version": "0.0.20", + "version": "0.0.21", "license": "MIT", "icon": "icon.png", "engines": { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index de76793..4ba6be3 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -280,6 +280,7 @@ function createMockVscode(tempRoot) { fileWatchers: [], watchers: [], workspaceFolderListeners: [], + workspaceFolderUpdates: [], configurationUpdates: [], workspaceConfigurationValues: new Map(), }; @@ -643,6 +644,11 @@ function createMockVscode(tempRoot) { } }); }, + updateWorkspaceFolders(start, deleteCount, ...folders) { + registrations.workspaceFolderUpdates.push({ start, deleteCount, folders }); + this.workspaceFolders.splice(start, deleteCount, ...folders); + return true; + }, workspaceFolders: [{ uri: { fsPath: tempRoot } }], }, ConfigurationTarget, @@ -1507,6 +1513,60 @@ test('active-agents extension registers tree and decoration providers', async () } }); +test('active-agents extension closes deleted worktree repositories during refresh', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-close-deleted-')); + 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', + })); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.workspaceFolders = [ + { uri: { fsPath: tempRoot } }, + { uri: { fsPath: worktreePath } }, + ]; + vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; + 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 }); + 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], + ); + + await registrations.commands.get('gitguardex.activeAgents.refresh')(); + await flushAsyncWork(); + assert.equal(gitCloseCalls().length, 1); + + 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 d96ae0b..d7fb051 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -2395,6 +2395,51 @@ function isPathWithin(parentPath, targetPath) { return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); } +function normalizeAbsolutePath(value) { + return typeof value === 'string' && value.trim() ? path.resolve(value) : ''; +} + +function removeDeletedWorktreeWorkspaceFolder(worktreePath) { + if (typeof vscode.workspace.updateWorkspaceFolders !== 'function') { + return false; + } + + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (!normalizedWorktreePath) { + return false; + } + + const workspaceFolders = vscode.workspace.workspaceFolders || []; + const folderIndex = workspaceFolders.findIndex((folder) => ( + normalizeAbsolutePath(folder?.uri?.fsPath) === normalizedWorktreePath + )); + if (folderIndex < 0) { + return false; + } + + try { + return vscode.workspace.updateWorkspaceFolders(folderIndex, 1) === true; + } catch (_error) { + return false; + } +} + +async function closeDeletedWorktreeRepository(worktreePath) { + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (!normalizedWorktreePath || fs.existsSync(normalizedWorktreePath)) { + return false; + } + + try { + await vscode.commands.executeCommand('git.close', vscode.Uri.file(normalizedWorktreePath)); + } catch (_error) { + // The Git extension may have already removed this repository. + } + + removeDeletedWorktreeWorkspaceFolder(normalizedWorktreePath); + return true; +} + function localizeChangeForSession(session, change) { if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) { return null; @@ -3434,6 +3479,7 @@ class ActiveAgentsRefreshController { this.inspectPanelManager = inspectPanelManager; this.refreshTimer = null; this.sessionWatchers = new Map(); + this.closedMissingWorktreeRepositories = new Set(); } scheduleRefresh() { @@ -3458,6 +3504,16 @@ class ActiveAgentsRefreshController { for (const entry of repoEntries) { for (const session of entry.sessions) { + const worktreePath = sessionWorktreePath(session); + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (normalizedWorktreePath && !fs.existsSync(normalizedWorktreePath)) { + await this.closeMissingWorktreeRepository(normalizedWorktreePath); + continue; + } + if (normalizedWorktreePath) { + this.closedMissingWorktreeRepositories.delete(normalizedWorktreePath); + } + const sessionKey = resolveSessionWatcherKey(session); liveSessionKeys.add(sessionKey); if (this.sessionWatchers.has(sessionKey)) { @@ -3483,6 +3539,16 @@ class ActiveAgentsRefreshController { } } + async closeMissingWorktreeRepository(worktreePath) { + const normalizedWorktreePath = normalizeAbsolutePath(worktreePath); + if (!normalizedWorktreePath || this.closedMissingWorktreeRepositories.has(normalizedWorktreePath)) { + return; + } + + this.closedMissingWorktreeRepositories.add(normalizedWorktreePath); + await closeDeletedWorktreeRepository(normalizedWorktreePath); + } + dispose() { if (this.refreshTimer) { clearTimeout(this.refreshTimer); diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 8c009a2..c725ca6 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", "publisher": "Recodee", - "version": "0.0.20", + "version": "0.0.21", "license": "MIT", "icon": "icon.png", "engines": {