From 42a363a0a2fbd47c3f7de13fec1a857bfa9f007d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 12:20:38 +0200 Subject: [PATCH] Self-heal stale VS Code repo-scan ignores in older Guardex repos Older repos can keep stale git.repositoryScanIgnoredFolders until setup or doctor reruns, so the Active Agents extension now merges the managed ignore list on activation and workspace-folder changes while preserving user entries and tolerating read-only settings. Constraint: Existing repos can have tracked .vscode/settings.json values that predate the newer managed ignore list Rejected: Require operators to rerun gx setup or gx doctor | stale repos stay noisy in default VS Code repo scan Confidence: high Scope-risk: narrow Directive: Keep the managed repo-scan ignore list in sync across src/context.js and the live/template Active Agents extension copies Tested: node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js (new self-heal regression and live-template sync assertions passed; unrelated semver baseline failures remain) Tested: openspec validate --specs Not-tested: Full Node suite without the pre-existing local semver module failure --- .../notes.md | 24 +++ .../vscode/guardex-active-agents/extension.js | 86 ++++++++++- ...vscode-active-agents-session-state.test.js | 138 ++++++++++++++++++ vscode/guardex-active-agents/extension.js | 86 ++++++++++- 4 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/agent-codex-self-heal-repo-scan-ignores-2026-04-23-12-12/notes.md 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 00000000..88a76337 --- /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 3bf55739..7c962c6d 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 45ef0438..6e5fda69 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 3bf55739..7c962c6d 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); }