From 9e69090e810bb3d7e15a7515a42a0cb7bc394112 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 18:15:42 +0200 Subject: [PATCH] Keep Active Agents install paths loadable across VS Code window churn VS Code caches patch-specific local extension locations across already-open windows, while our installer was deleting those patch directories on each update. This change installs the companion into a canonical directory, refreshes a bounded same-major/minor patch compatibility window, and aligns the install/update messaging so stale windows keep resolving the companion until they reload. Constraint: Already-open VS Code windows can keep older local extension paths cached until reload Rejected: Document reload-only recovery | did not restore deleted patch paths for already-open windows Confidence: medium Scope-risk: narrow Reversibility: clean Directive: Keep the installer and the extension auto-update prompt aligned on reload-each-window wording when the local install layout changes Tested: node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js; openspec validate agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11 --type change --strict; openspec validate --specs; node scripts/install-vscode-active-agents-extension.js Not-tested: visual confirmation of the SCM surface after reloading every already-open VS Code window --- .../.openspec.yaml | 2 + .../proposal.md | 17 +++++++ .../vscode-active-agents-extension/spec.md | 27 ++++++++++ .../tasks.md | 38 ++++++++++++++ .../install-vscode-active-agents-extension.js | 49 ++++++++++++++----- .../install-vscode-active-agents-extension.js | 49 ++++++++++++++----- .../vscode/guardex-active-agents/extension.js | 2 +- ...vscode-active-agents-session-state.test.js | 43 ++++++++++------ vscode/guardex-active-agents/extension.js | 2 +- 9 files changed, 189 insertions(+), 40 deletions(-) create mode 100644 openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/.openspec.yaml create mode 100644 openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/proposal.md create mode 100644 openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/specs/vscode-active-agents-extension/spec.md create mode 100644 openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/tasks.md diff --git a/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/.openspec.yaml b/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/.openspec.yaml new file mode 100644 index 0000000..25345f4 --- /dev/null +++ b/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-22 diff --git a/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/proposal.md b/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/proposal.md new file mode 100644 index 0000000..e584762 --- /dev/null +++ b/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/proposal.md @@ -0,0 +1,17 @@ +## Why + +- The Active Agents companion currently installs into a versioned VS Code extension directory and deletes older patch directories on each update. +- VS Code keeps patch-specific extension locations cached across already-open windows, so pruning those directories can leave one window showing `Active Agents Commit` while another window can no longer resolve the companion until reload. + +## What Changes + +- Install the Active Agents companion into one canonical local extension directory derived from the extension id. +- Refresh a bounded same-major/minor patch compatibility window so already-open windows that still hold a recent older patch path can keep resolving the extension until reload. +- Update the installer output to tell the user to reload each already-open VS Code window after install/update. +- Add focused regression coverage for the new install layout. + +## Impact + +- Affects only the local VS Code companion install surface under `~/.vscode/extensions` plus installer regression tests. +- Keeps a small bounded set of patch compatibility copies to avoid stale-window breakage during rapid local iteration. +- Does not change the runtime feature set of the extension itself. diff --git a/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..bf0c405 --- /dev/null +++ b/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Active Agents local installs use a canonical extension directory +The Active Agents install flow SHALL publish the companion into one canonical local VS Code extension directory instead of making the newest versioned patch directory the only live copy. + +#### Scenario: Installer refreshes the canonical install path +- **WHEN** `scripts/install-vscode-active-agents-extension.js` installs the companion +- **THEN** it writes the current extension payload into a stable local extension directory derived from the extension id +- **AND** that directory contains the current manifest, runtime entrypoint, session schema, and packaged assets +- **AND** focused regression coverage validates the installed payload. + +### Requirement: Recent patch-version install paths stay loadable until reload +The Active Agents install flow SHALL keep recent same-major/minor patch-version install paths resolvable so already-open VS Code windows do not lose the companion because an older cached location was pruned before reload. + +#### Scenario: Installer refreshes compatibility copies for recent patch paths +- **WHEN** the current companion version is `X.Y.Z` +- **THEN** the installer refreshes compatibility directories for a bounded recent patch window within `X.Y.*` +- **AND** the current patch-version directory stays loadable +- **AND** already-open windows that still point at a recent earlier patch path can continue resolving the extension until the window reloads. + +### Requirement: Install output tells users to reload already-open windows +The Active Agents install flow SHALL tell the user that every already-open VS Code window needs a reload after install or auto-update. + +#### Scenario: Install completes successfully +- **WHEN** the installer finishes copying the companion +- **THEN** stdout includes the installed version and canonical target directory +- **AND** stdout explicitly tells the user to reload each already-open VS Code window to pick up the newest companion. diff --git a/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/tasks.md b/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/tasks.md new file mode 100644 index 0000000..9a63b2c --- /dev/null +++ b/openspec/changes/agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11/tasks.md @@ -0,0 +1,38 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11`; branch=`agent/codex/fix-active-agents-install-stale-window-2026-04-22-18-11`; scope=`canonical Active Agents install path plus recent patch-path compatibility copies and reload wording`; action=`patch the installer/tests in this sandbox, verify, then finish cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11`. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-extension/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement the canonical install path and compatibility copy behavior. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +Verification note: +- `node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js` passed `55/55`. +- `openspec validate agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11 --type change --strict` returned `Change 'agent-codex-fix-active-agents-install-stale-window-2026-04-22-18-11' is valid`. +- `openspec validate --specs` returned `No items found to validate.` in this repo state. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/fix-active-agents-install-stale-window-2026-04-22-18-11 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/scripts/install-vscode-active-agents-extension.js b/scripts/install-vscode-active-agents-extension.js index 9a7647f..7fc448d 100755 --- a/scripts/install-vscode-active-agents-extension.js +++ b/scripts/install-vscode-active-agents-extension.js @@ -4,6 +4,8 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const PATCH_COMPATIBILITY_WINDOW = 20; + function parseOptions(argv) { const options = {}; for (let index = 0; index < argv.length; index += 1) { @@ -43,6 +45,26 @@ function removeIfExists(targetPath) { } } +function parseSimpleSemver(version) { + const parts = String(version || '').trim().split('.').map((part) => Number.parseInt(part, 10)); + if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) { + throw new Error(`Expected simple semver for the Active Agents companion, received "${version}".`); + } + return parts; +} + +function buildInstallTargets(extensionId, version, extensionsDir) { + const [major, minor, patch] = parseSimpleSemver(version); + const firstCompatiblePatch = Math.max(0, patch - PATCH_COMPATIBILITY_WINDOW); + const targets = [path.join(extensionsDir, extensionId)]; + + for (let compatiblePatch = firstCompatiblePatch; compatiblePatch <= patch; compatiblePatch += 1) { + targets.push(path.join(extensionsDir, `${extensionId}-${major}.${minor}.${compatiblePatch}`)); + } + + return targets; +} + function main() { const repoRoot = path.resolve(__dirname, '..'); const options = parseOptions(process.argv.slice(2)); @@ -57,30 +79,35 @@ function main() { ); fs.mkdirSync(extensionsDir, { recursive: true }); - const targetDir = path.join(extensionsDir, `${extensionId}-${manifest.version}`); + const targetDirs = buildInstallTargets(extensionId, manifest.version, extensionsDir); + const canonicalTargetDir = targetDirs[0]; + const keepDirNames = new Set(targetDirs.map((targetDir) => path.basename(targetDir))); for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } - if (entry.name === path.basename(targetDir)) { + if (keepDirNames.has(entry.name)) { continue; } - if (entry.name.startsWith(`${extensionId}-`)) { + if (entry.name === extensionId || entry.name.startsWith(`${extensionId}-`)) { removeIfExists(path.join(extensionsDir, entry.name)); } } - removeIfExists(targetDir); - fs.cpSync(sourceDir, targetDir, { - recursive: true, - force: true, - preserveTimestamps: true, - }); + for (const targetDir of targetDirs) { + removeIfExists(targetDir); + fs.cpSync(sourceDir, targetDir, { + recursive: true, + force: true, + preserveTimestamps: true, + }); + } process.stdout.write( - `[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${targetDir}\n` + - '[guardex-active-agents] Reload the VS Code window to activate the Source Control companion.\n', + `[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${canonicalTargetDir}\n` + + `[guardex-active-agents] Refreshed ${targetDirs.length - 1} recent patch compatibility path(s) for already-open windows.\n` + + '[guardex-active-agents] Reload each already-open VS Code window to activate the newest Source Control companion.\n', ); } diff --git a/templates/scripts/install-vscode-active-agents-extension.js b/templates/scripts/install-vscode-active-agents-extension.js index 9a7647f..7fc448d 100755 --- a/templates/scripts/install-vscode-active-agents-extension.js +++ b/templates/scripts/install-vscode-active-agents-extension.js @@ -4,6 +4,8 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const PATCH_COMPATIBILITY_WINDOW = 20; + function parseOptions(argv) { const options = {}; for (let index = 0; index < argv.length; index += 1) { @@ -43,6 +45,26 @@ function removeIfExists(targetPath) { } } +function parseSimpleSemver(version) { + const parts = String(version || '').trim().split('.').map((part) => Number.parseInt(part, 10)); + if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) { + throw new Error(`Expected simple semver for the Active Agents companion, received "${version}".`); + } + return parts; +} + +function buildInstallTargets(extensionId, version, extensionsDir) { + const [major, minor, patch] = parseSimpleSemver(version); + const firstCompatiblePatch = Math.max(0, patch - PATCH_COMPATIBILITY_WINDOW); + const targets = [path.join(extensionsDir, extensionId)]; + + for (let compatiblePatch = firstCompatiblePatch; compatiblePatch <= patch; compatiblePatch += 1) { + targets.push(path.join(extensionsDir, `${extensionId}-${major}.${minor}.${compatiblePatch}`)); + } + + return targets; +} + function main() { const repoRoot = path.resolve(__dirname, '..'); const options = parseOptions(process.argv.slice(2)); @@ -57,30 +79,35 @@ function main() { ); fs.mkdirSync(extensionsDir, { recursive: true }); - const targetDir = path.join(extensionsDir, `${extensionId}-${manifest.version}`); + const targetDirs = buildInstallTargets(extensionId, manifest.version, extensionsDir); + const canonicalTargetDir = targetDirs[0]; + const keepDirNames = new Set(targetDirs.map((targetDir) => path.basename(targetDir))); for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } - if (entry.name === path.basename(targetDir)) { + if (keepDirNames.has(entry.name)) { continue; } - if (entry.name.startsWith(`${extensionId}-`)) { + if (entry.name === extensionId || entry.name.startsWith(`${extensionId}-`)) { removeIfExists(path.join(extensionsDir, entry.name)); } } - removeIfExists(targetDir); - fs.cpSync(sourceDir, targetDir, { - recursive: true, - force: true, - preserveTimestamps: true, - }); + for (const targetDir of targetDirs) { + removeIfExists(targetDir); + fs.cpSync(sourceDir, targetDir, { + recursive: true, + force: true, + preserveTimestamps: true, + }); + } process.stdout.write( - `[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${targetDir}\n` + - '[guardex-active-agents] Reload the VS Code window to activate the Source Control companion.\n', + `[guardex-active-agents] Installed ${extensionId}@${manifest.version} to ${canonicalTargetDir}\n` + + `[guardex-active-agents] Refreshed ${targetDirs.length - 1} recent patch compatibility path(s) for already-open windows.\n` + + '[guardex-active-agents] Reload each already-open VS Code window to activate the newest Source Control companion.\n', ); } diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index df11917..e455245 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -899,7 +899,7 @@ async function maybeAutoUpdateActiveAgentsExtension(context) { } const selection = await vscode.window.showInformationMessage?.( - `GitGuardex Active Agents updated to ${candidate.version}. Reload Window to use the newest companion.`, + `GitGuardex Active Agents updated to ${candidate.version}. Reload this window now, then reload any other already-open VS Code windows to use the newest companion.`, RELOAD_WINDOW_ACTION, UPDATE_LATER_ACTION, ); diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 80ba921..90a763a 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1087,33 +1087,44 @@ test('session-schema reads inspect data from base-branch config, log tail, and h ); }); -test('install-vscode-active-agents-extension installs the current extension version and prunes older copies', () => { +test('install-vscode-active-agents-extension installs the current extension into a canonical dir and refreshes recent patch compatibility copies', () => { const tempExtensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-ext-')); const manifest = readExtensionManifest(); - const staleDir = path.join(tempExtensionsDir, 'recodeee.gitguardex-active-agents-0.0.0'); - fs.mkdirSync(staleDir, { recursive: true }); - fs.writeFileSync(path.join(staleDir, 'stale.txt'), 'old', 'utf8'); + const extensionId = `${manifest.publisher}.${manifest.name}`; + const [major, minor, patch] = parseSimpleSemver(manifest.version); + const canonicalDir = path.join(tempExtensionsDir, extensionId); + const currentVersionDir = path.join(tempExtensionsDir, `${extensionId}-${manifest.version}`); + const recentCompatDir = patch > 0 + ? path.join(tempExtensionsDir, `${extensionId}-${major}.${minor}.${patch - 1}`) + : currentVersionDir; + const farLegacyDir = path.join(tempExtensionsDir, `${extensionId}-99.99.99`); + + fs.mkdirSync(recentCompatDir, { recursive: true }); + fs.writeFileSync(path.join(recentCompatDir, 'stale.txt'), 'old', 'utf8'); + fs.mkdirSync(farLegacyDir, { recursive: true }); + fs.writeFileSync(path.join(farLegacyDir, 'stale.txt'), 'old', 'utf8'); const result = runNode(installScript, ['--extensions-dir', tempExtensionsDir], { cwd: repoRoot, }); assert.equal(result.status, 0, result.stderr); - const installedDir = path.join( - tempExtensionsDir, - `recodeee.gitguardex-active-agents-${manifest.version}`, - ); - const installedManifest = readJson(path.join(installedDir, 'package.json')); - assert.equal(fs.existsSync(installedDir), true); - assert.equal(fs.existsSync(path.join(installedDir, 'extension.js')), true); - assert.equal(fs.existsSync(path.join(installedDir, 'session-schema.js')), true); + const installedManifest = readJson(path.join(canonicalDir, 'package.json')); + assert.equal(fs.existsSync(canonicalDir), true); + assert.equal(fs.existsSync(path.join(canonicalDir, 'extension.js')), true); + assert.equal(fs.existsSync(path.join(canonicalDir, 'session-schema.js')), true); assert.equal(installedManifest.icon, 'icon.png'); assert.equal(installedManifest.version, manifest.version); assert.deepEqual(installedManifest.activationEvents, manifest.activationEvents); assert.equal(installedManifest.activationEvents.includes('onStartupFinished'), true); - assert.equal(fs.existsSync(path.join(installedDir, 'icon.png')), true); - assert.equal(fs.existsSync(staleDir), false); - assert.match(result.stdout, /Reload the VS Code window/); + assert.equal(fs.existsSync(path.join(canonicalDir, 'icon.png')), true); + assert.equal(fs.existsSync(currentVersionDir), true); + assert.equal(fs.existsSync(path.join(recentCompatDir, 'package.json')), true); + assert.equal(fs.existsSync(path.join(recentCompatDir, 'stale.txt')), false); + assert.equal(fs.existsSync(farLegacyDir), false); + assert.match(result.stdout, new RegExp(`Installed ${extensionId}@${manifest.version} to ${canonicalDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + assert.match(result.stdout, /Refreshed \d+ recent patch compatibility path\(s\)/); + assert.match(result.stdout, /Reload each already-open VS Code window/); }); test('active-agents extension edits require a higher manifest version than the base branch', () => { @@ -1196,7 +1207,7 @@ test('active-agents extension auto-installs a newer workspace build and offers r assert.equal(execCalls[0].options.encoding, 'utf8'); assert.match( registrations.informationMessages.at(-1), - /GitGuardex Active Agents updated to 9\.9\.9/, + /GitGuardex Active Agents updated to 9\.9\.9.*reload any other already-open VS Code windows/i, ); assert.deepEqual(registrations.infoMessages.at(-1).slice(1), ['Reload Window', 'Later']); assert.equal( diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index df11917..e455245 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -899,7 +899,7 @@ async function maybeAutoUpdateActiveAgentsExtension(context) { } const selection = await vscode.window.showInformationMessage?.( - `GitGuardex Active Agents updated to ${candidate.version}. Reload Window to use the newest companion.`, + `GitGuardex Active Agents updated to ${candidate.version}. Reload this window now, then reload any other already-open VS Code windows to use the newest companion.`, RELOAD_WINDOW_ACTION, UPDATE_LATER_ACTION, );