diff --git a/openspec/changes/agent-codex-self-heal-repo-scan-ignores-2026-04-23-12-12/notes.md b/openspec/changes/agent-codex-self-heal-repo-scan-ignores-2026-04-23-12-12/notes.md new file mode 100644 index 0000000..88a7633 --- /dev/null +++ b/openspec/changes/agent-codex-self-heal-repo-scan-ignores-2026-04-23-12-12/notes.md @@ -0,0 +1,24 @@ +# agent-codex-self-heal-repo-scan-ignores-2026-04-23-12-12 (minimal / T1) + +Branch: `agent/codex/self-heal-repo-scan-ignores-2026-04-23-12-12` + +Older repos can keep stale `.vscode/settings.json` values for `git.repositoryScanIgnoredFolders` until operators rerun `gx setup` or `gx doctor`. The shipped `Active Agents` extension should self-heal that workspace setting on activation and whenever workspace folders change so nested `.omx/.omc` helper worktrees stop leaking back into the default VS Code repo scan. + +Scope: +- Update `vscode/guardex-active-agents/extension.js` to merge the managed repo-scan ignore folders into live workspace Git settings during activation and workspace-folder changes, while tolerating read-only settings. +- Mirror the same change into `templates/vscode/guardex-active-agents/extension.js` so shipped and template sources stay in sync. +- Add one focused regression in `test/vscode-active-agents-session-state.test.js` that proves activation/workspace-folder self-healing preserves existing user entries and avoids duplicate managed paths. + +Verification: +- `node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js` +- `openspec validate --specs` + +## Handoff + +- Handoff: change=`agent-codex-self-heal-repo-scan-ignores-2026-04-23-12-12`; branch=`agent/codex/self-heal-repo-scan-ignores-2026-04-23-12-12`; scope=`vscode/guardex-active-agents/extension.js, templates/vscode/guardex-active-agents/extension.js, test/vscode-active-agents-session-state.test.js, openspec/changes/agent-codex-self-heal-repo-scan-ignores-2026-04-23-12-12/notes.md`; action=`self-heal managed repo-scan ignores from the Active Agents extension, verify with focused node tests plus openspec validation, then finish via PR merge + cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/self-heal-repo-scan-ignores-2026-04-23-12-12 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 3bf5573..7c962c6 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -28,6 +28,18 @@ const RELOAD_WINDOW_ACTION = 'Reload Window'; const UPDATE_LATER_ACTION = 'Later'; const REFRESH_POLL_INTERVAL_MS = 30_000; const INSPECT_PANEL_VIEW_TYPE = 'gitguardex.activeAgents.inspect'; +const GIT_CONFIGURATION_SECTION = 'git'; +const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'repositoryScanIgnoredFolders'; +const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [ + '.omx/agent-worktrees', + '**/.omx/agent-worktrees', + '.omx/.tmp-worktrees', + '**/.omx/.tmp-worktrees', + '.omc/agent-worktrees', + '**/.omc/agent-worktrees', + '.omc/.tmp-worktrees', + '**/.omc/.tmp-worktrees', +]; const SESSION_ACTIVITY_GROUPS = [ { kind: 'blocked', label: 'BLOCKED' }, { kind: 'working', label: 'WORKING NOW' }, @@ -105,6 +117,73 @@ function formatCountLabel(count, singular, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; } +function uniqueStringList(values) { + const seen = new Set(); + const result = []; + + for (const value of values) { + if (typeof value !== 'string' || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + + return result; +} + +function stringListsEqual(left, right) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + + return left.every((value, index) => value === right[index]); +} + +async function ensureManagedRepoScanIgnores() { + if (typeof vscode.workspace.getConfiguration !== 'function') { + return; + } + + const workspaceFolders = vscode.workspace.workspaceFolders || []; + if (workspaceFolders.length === 0) { + return; + } + + const workspaceFolderTarget = workspaceFolders.length > 1 + ? vscode.ConfigurationTarget?.WorkspaceFolder + : vscode.ConfigurationTarget?.Workspace; + if (workspaceFolderTarget === undefined) { + return; + } + + for (const workspaceFolder of workspaceFolders) { + const gitConfig = vscode.workspace.getConfiguration(GIT_CONFIGURATION_SECTION, workspaceFolder); + const configuredIgnoredFolders = gitConfig.get(REPO_SCAN_IGNORED_FOLDERS_SETTING); + const existingIgnoredFolders = Array.isArray(configuredIgnoredFolders) + ? configuredIgnoredFolders + : []; + const nextIgnoredFolders = uniqueStringList([ + ...existingIgnoredFolders, + ...MANAGED_REPO_SCAN_IGNORED_FOLDERS, + ]); + + if (stringListsEqual(existingIgnoredFolders, nextIgnoredFolders)) { + continue; + } + + try { + await gitConfig.update( + REPO_SCAN_IGNORED_FOLDERS_SETTING, + nextIgnoredFolders, + workspaceFolderTarget, + ); + } catch { + // Leave the extension usable even when the current workspace settings cannot be updated. + } + } +} + function sessionIdentityLabel(session) { const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : ''; const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : ''; @@ -1777,6 +1856,10 @@ function activate(context) { activeAgentsStatusItem.command = 'gitguardex.activeAgents.focus'; provider.attachTreeView(treeView); const scheduleRefresh = () => refreshController.scheduleRefresh(); + const handleWorkspaceFoldersChanged = () => { + scheduleRefresh(); + void ensureManagedRepoScanIgnores(); + }; const refresh = () => void refreshController.refreshNow(); const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); @@ -1902,7 +1985,7 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), - vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh), + vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged), activeSessionsWatcher, lockWatcher, worktreeLockWatcher, @@ -1916,6 +1999,7 @@ function activate(context) { ...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh), ...bindRefreshWatcher(logWatcher, scheduleRefresh), ); + void ensureManagedRepoScanIgnores(); void refreshController.refreshNow(); void maybeAutoUpdateActiveAgentsExtension(context); } diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 45ef043..6e5fda6 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -244,6 +244,8 @@ function createMockVscode(tempRoot) { fileWatchers: [], watchers: [], workspaceFolderListeners: [], + configurationUpdates: [], + workspaceConfigurationValues: new Map(), }; class TreeItem { @@ -293,6 +295,29 @@ function createMockVscode(tempRoot) { } const disposable = (onDispose) => ({ dispose: onDispose || (() => {}) }); + const ConfigurationTarget = { + Workspace: 'workspace', + WorkspaceFolder: 'workspaceFolder', + }; + const configurationKey = (section, scopePath, key) => `${section}::${scopePath}::${key}`; + const resolveWorkspaceScopePath = (scope) => scope?.uri?.fsPath || tempRoot; + const readConfigurationValue = (section, scope, key) => { + const scopePath = resolveWorkspaceScopePath(scope); + const scopedKey = configurationKey(section, scopePath, key); + if (registrations.workspaceConfigurationValues.has(scopedKey)) { + return registrations.workspaceConfigurationValues.get(scopedKey); + } + return registrations.workspaceConfigurationValues.get(configurationKey(section, tempRoot, key)); + }; + const writeConfigurationValue = (section, scopePath, key, value) => { + registrations.workspaceConfigurationValues.set(configurationKey(section, scopePath, key), value); + }; + registrations.getConfigurationValue = (section, scopePath, key) => ( + registrations.workspaceConfigurationValues.get(configurationKey(section, scopePath, key)) + ); + registrations.setConfigurationValue = (section, scopePath, key, value) => { + writeConfigurationValue(section, scopePath, key, value); + }; function createFileWatcher(pattern) { const listeners = { @@ -553,6 +578,16 @@ function createMockVscode(tempRoot) { }, createFileSystemWatcher: (pattern) => createFileWatcher(pattern), findFiles: async () => [], + getConfiguration: (section, scope) => ({ + get: (key) => readConfigurationValue(section, scope, key), + update: async (key, value, target) => { + const scopePath = target === ConfigurationTarget.WorkspaceFolder + ? resolveWorkspaceScopePath(scope) + : tempRoot; + registrations.configurationUpdates.push({ section, key, scopePath, target, value }); + writeConfigurationValue(section, scopePath, key, value); + }, + }), onDidChangeWorkspaceFolders: (listener) => { registrations.workspaceFolderListeners.push(listener); return disposable(() => { @@ -564,6 +599,7 @@ function createMockVscode(tempRoot) { }, workspaceFolders: [{ uri: { fsPath: tempRoot } }], }, + ConfigurationTarget, ThemeColor, }, }; @@ -1313,6 +1349,108 @@ test('active-agents extension registers tree and decoration providers', async () } }); +test('active-agents extension self-heals managed repo-scan ignores on activation and workspace changes', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-scan-ignores-')); + const secondRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-scan-ignores-second-')); + const { registrations, vscode } = createMockVscode(tempRoot); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + const managedRepoScanIgnoredFolders = [ + '.omx/agent-worktrees', + '**/.omx/agent-worktrees', + '.omx/.tmp-worktrees', + '**/.omx/.tmp-worktrees', + '.omc/agent-worktrees', + '**/.omc/agent-worktrees', + '.omc/.tmp-worktrees', + '**/.omc/.tmp-worktrees', + ]; + const mergeManagedRepoScanIgnores = (values) => Array.from(new Set([ + ...values, + ...managedRepoScanIgnoredFolders, + ])); + + registrations.setConfigurationValue('git', tempRoot, 'repositoryScanIgnoredFolders', [ + 'custom-ignore', + '.omx/agent-worktrees', + '.omx/agent-worktrees', + ]); + + extension.activate(context); + await flushAsyncWork(); + + assert.deepEqual( + registrations.getConfigurationValue('git', tempRoot, 'repositoryScanIgnoredFolders'), + mergeManagedRepoScanIgnores([ + 'custom-ignore', + '.omx/agent-worktrees', + '.omx/agent-worktrees', + ]), + ); + assert.deepEqual(registrations.configurationUpdates, [ + { + section: 'git', + key: 'repositoryScanIgnoredFolders', + scopePath: tempRoot, + target: vscode.ConfigurationTarget.Workspace, + value: mergeManagedRepoScanIgnores([ + 'custom-ignore', + '.omx/agent-worktrees', + '.omx/agent-worktrees', + ]), + }, + ]); + + registrations.setConfigurationValue('git', secondRoot, 'repositoryScanIgnoredFolders', [ + 'second-ignore', + '.omc/agent-worktrees', + ]); + vscode.workspace.workspaceFolders = [ + { uri: { fsPath: tempRoot } }, + { uri: { fsPath: secondRoot } }, + ]; + registrations.workspaceFolderListeners[0]({ + added: [{ uri: { fsPath: secondRoot } }], + removed: [], + }); + await flushAsyncWork(); + + assert.deepEqual( + registrations.getConfigurationValue('git', secondRoot, 'repositoryScanIgnoredFolders'), + mergeManagedRepoScanIgnores([ + 'second-ignore', + '.omc/agent-worktrees', + ]), + ); + assert.deepEqual(registrations.configurationUpdates, [ + { + section: 'git', + key: 'repositoryScanIgnoredFolders', + scopePath: tempRoot, + target: vscode.ConfigurationTarget.Workspace, + value: mergeManagedRepoScanIgnores([ + 'custom-ignore', + '.omx/agent-worktrees', + '.omx/agent-worktrees', + ]), + }, + { + section: 'git', + key: 'repositoryScanIgnoredFolders', + scopePath: secondRoot, + target: vscode.ConfigurationTarget.WorkspaceFolder, + value: mergeManagedRepoScanIgnores([ + 'second-ignore', + '.omc/agent-worktrees', + ]), + }, + ]); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension startAgent command prefers the Guardex launcher in a terminal', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-start-agent-')); fs.mkdirSync(path.join(tempRoot, 'scripts'), { recursive: true }); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 3bf5573..7c962c6 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -28,6 +28,18 @@ const RELOAD_WINDOW_ACTION = 'Reload Window'; const UPDATE_LATER_ACTION = 'Later'; const REFRESH_POLL_INTERVAL_MS = 30_000; const INSPECT_PANEL_VIEW_TYPE = 'gitguardex.activeAgents.inspect'; +const GIT_CONFIGURATION_SECTION = 'git'; +const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'repositoryScanIgnoredFolders'; +const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [ + '.omx/agent-worktrees', + '**/.omx/agent-worktrees', + '.omx/.tmp-worktrees', + '**/.omx/.tmp-worktrees', + '.omc/agent-worktrees', + '**/.omc/agent-worktrees', + '.omc/.tmp-worktrees', + '**/.omc/.tmp-worktrees', +]; const SESSION_ACTIVITY_GROUPS = [ { kind: 'blocked', label: 'BLOCKED' }, { kind: 'working', label: 'WORKING NOW' }, @@ -105,6 +117,73 @@ function formatCountLabel(count, singular, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; } +function uniqueStringList(values) { + const seen = new Set(); + const result = []; + + for (const value of values) { + if (typeof value !== 'string' || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + + return result; +} + +function stringListsEqual(left, right) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + + return left.every((value, index) => value === right[index]); +} + +async function ensureManagedRepoScanIgnores() { + if (typeof vscode.workspace.getConfiguration !== 'function') { + return; + } + + const workspaceFolders = vscode.workspace.workspaceFolders || []; + if (workspaceFolders.length === 0) { + return; + } + + const workspaceFolderTarget = workspaceFolders.length > 1 + ? vscode.ConfigurationTarget?.WorkspaceFolder + : vscode.ConfigurationTarget?.Workspace; + if (workspaceFolderTarget === undefined) { + return; + } + + for (const workspaceFolder of workspaceFolders) { + const gitConfig = vscode.workspace.getConfiguration(GIT_CONFIGURATION_SECTION, workspaceFolder); + const configuredIgnoredFolders = gitConfig.get(REPO_SCAN_IGNORED_FOLDERS_SETTING); + const existingIgnoredFolders = Array.isArray(configuredIgnoredFolders) + ? configuredIgnoredFolders + : []; + const nextIgnoredFolders = uniqueStringList([ + ...existingIgnoredFolders, + ...MANAGED_REPO_SCAN_IGNORED_FOLDERS, + ]); + + if (stringListsEqual(existingIgnoredFolders, nextIgnoredFolders)) { + continue; + } + + try { + await gitConfig.update( + REPO_SCAN_IGNORED_FOLDERS_SETTING, + nextIgnoredFolders, + workspaceFolderTarget, + ); + } catch { + // Leave the extension usable even when the current workspace settings cannot be updated. + } + } +} + function sessionIdentityLabel(session) { const agentName = typeof session?.agentName === 'string' ? session.agentName.trim() : ''; const taskName = typeof session?.taskName === 'string' ? session.taskName.trim() : ''; @@ -1777,6 +1856,10 @@ function activate(context) { activeAgentsStatusItem.command = 'gitguardex.activeAgents.focus'; provider.attachTreeView(treeView); const scheduleRefresh = () => refreshController.scheduleRefresh(); + const handleWorkspaceFoldersChanged = () => { + scheduleRefresh(); + void ensureManagedRepoScanIgnores(); + }; const refresh = () => void refreshController.refreshNow(); const activeSessionsWatcher = vscode.workspace.createFileSystemWatcher(ACTIVE_SESSION_FILES_GLOB); const lockWatcher = vscode.workspace.createFileSystemWatcher(AGENT_FILE_LOCKS_GLOB); @@ -1902,7 +1985,7 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), - vscode.workspace.onDidChangeWorkspaceFolders(scheduleRefresh), + vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged), activeSessionsWatcher, lockWatcher, worktreeLockWatcher, @@ -1916,6 +1999,7 @@ function activate(context) { ...bindRefreshWatcher(worktreeLockWatcher, scheduleRefresh), ...bindRefreshWatcher(logWatcher, scheduleRefresh), ); + void ensureManagedRepoScanIgnores(); void refreshController.refreshNow(); void maybeAutoUpdateActiveAgentsExtension(context); }