From acde0004e5e473206ea74bac2304632774d501e7 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 11:15:18 +0200 Subject: [PATCH] Surface lock ownership directly in the VS Code active agents tree The active-agents view already showed sessions and repo changes, but it hid file-lock ownership and offered no conflict signal when repo-root edits were claimed by another branch. This change caches the lock registry per repo, appends lock counts to session rows, warns on foreign-branch locks in repo-root changes, refreshes that cache from lock-file watcher events, and keeps the lock registry file itself out of the CHANGES tree. Constraint: Repo-root change warnings compare against the repo worktree's current branch, because CHANGES rows belong to that checkout rather than an arbitrary active session Rejected: Re-read the lock file inside every getChildren() call | repeated IO on tree expansion with no freshness benefit over watcher events Confidence: high Scope-risk: narrow Directive: Keep runtime and template VS Code extension copies in sync whenever the tree-provider data model changes Tested: node --test test/vscode-active-agents-session-state.test.js; openspec validate agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09 --type change --strict Not-tested: Interactive rendering in a real VS Code window --- .../proposal.md | 18 ++ .../vscode-active-agents-extension/spec.md | 45 +++++ .../tasks.md | 32 ++++ .../vscode/guardex-active-agents/extension.js | 14 ++ ...vscode-active-agents-session-state.test.js | 174 ++++++++++++++++++ vscode/guardex-active-agents/extension.js | 14 ++ 6 files changed, 297 insertions(+) create mode 100644 openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/proposal.md create mode 100644 openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/specs/vscode-active-agents-extension/spec.md create mode 100644 openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/tasks.md diff --git a/openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/proposal.md b/openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/proposal.md new file mode 100644 index 0000000..5cc2bbb --- /dev/null +++ b/openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/proposal.md @@ -0,0 +1,18 @@ +## Why + +- The Active Agents tree shows live sessions and repo-root changes, but it does not surface lock ownership from `.omx/state/agent-file-locks.json`. +- Operators cannot tell which active session owns how many locks, and repo-root changes can silently overlap a different branch's claimed file. + +## What Changes + +- Cache the lock registry per repo inside the Active Agents provider. +- Append `🔒 N` to each session row label using the count of locks owned by that session branch. +- Mark repo-root change rows with a warning icon when the file is locked by a different branch than the repo worktree's current branch, and show the owner branch in the tooltip. +- Refresh cached lock state from watcher events on `.omx/state/agent-file-locks.json` instead of re-reading it on every `getChildren()` call. +- Exclude the lock registry file itself from repo-root `CHANGES` rows. + +## Impact + +- Makes lock ownership visible directly in the VS Code Source Control companion. +- Warns on cross-branch lock conflicts in repo-root changes. +- Keeps tree expansion cheap by moving lock re-reads to file watcher events. diff --git a/openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..b5430aa --- /dev/null +++ b/openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Session rows show lock ownership counts + +The Active Agents tree MUST append `🔒 N` to each session row, where `N` is the number of lock-registry entries owned by that session's branch. + +#### Scenario: session row includes branch lock count + +- **WHEN** `.omx/state/agent-file-locks.json` contains entries owned by an active session branch +- **THEN** the rendered session row label includes `🔒 ` +- **AND** the session tooltip includes the same lock count + +### Requirement: Repo-root changes warn on foreign locks + +Repo-root `CHANGES` rows MUST warn when a changed file is claimed by a different branch than the repo worktree's current branch. + +#### Scenario: repo-root change is locked by another branch + +- **WHEN** a repo-root changed file appears in `.omx/state/agent-file-locks.json` +- **AND** the lock owner branch differs from the repo worktree's current branch +- **THEN** the corresponding `ChangeItem` uses a warning icon +- **AND** the tooltip names the lock owner branch + +### Requirement: Lock registry reads are watcher-driven + +The Active Agents provider MUST refresh cached lock state from lock-file watcher events and MUST NOT re-read the lock registry on every tree load. + +#### Scenario: repeated tree loads do not re-read unchanged lock state + +- **WHEN** the tree is loaded multiple times without a lock-file watcher event +- **THEN** the provider reuses cached lock state + +#### Scenario: lock-file watcher refreshes cache + +- **WHEN** `.omx/state/agent-file-locks.json` changes +- **THEN** the lock watcher refreshes the provider cache before the next tree render + +### Requirement: Lock registry file is hidden from repo-root changes + +The repo-root `CHANGES` section MUST ignore `.omx/state/agent-file-locks.json`. + +#### Scenario: lock registry file is modified + +- **WHEN** `.omx/state/agent-file-locks.json` is dirty in the repo root +- **THEN** it does not render as a `ChangeItem` diff --git a/openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/tasks.md b/openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/tasks.md new file mode 100644 index 0000000..2a5c9b4 --- /dev/null +++ b/openspec/changes/agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09/tasks.md @@ -0,0 +1,32 @@ +## 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, append a `BLOCKED:` line under section 4 and stop. + +## 1. Specification + +- [x] 1.1 Capture the lock badge, foreign-lock warning, watcher refresh, and lock-file filtering behavior in branch-local OpenSpec artifacts. + +## 2. Implementation + +- [x] 2.1 Cache `.omx/state/agent-file-locks.json` per repo inside the Active Agents provider. +- [x] 2.2 Append `🔒 N` to each session row from the owning branch's lock count. +- [x] 2.3 Warn on repo-root change rows when the lock owner branch differs from the repo worktree branch. +- [x] 2.4 Refresh cached lock state from lock-file watcher events instead of per-`getChildren()` parsing. +- [x] 2.5 Exclude `.omx/state/agent-file-locks.json` from repo-root `CHANGES`. +- [x] 2.6 Mirror the runtime changes into `templates/vscode/guardex-active-agents/*`. +- [x] 2.7 Add focused regression coverage for lock badges, foreign-lock warnings, and watcher-driven re-reads. + +## 3. Verification + +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-vscode-tree-lock-decorations-clean-2026-04-22-11-09 --type change --strict`. + +## 4. Cleanup + +- [ ] 4.1 Run `bash scripts/agent-branch-finish.sh --branch agent/codex/vscode-tree-lock-decorations-clean-2026-04-22-11-09 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone and the branch refs are cleaned up. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 4f99bf1..9a8d554 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -913,6 +913,20 @@ class ActiveAgentsProvider { this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); } + readLockRegistryForRepo(repoRoot) { + const lockRegistry = readLockRegistry(repoRoot); + this.lockRegistryByRepoRoot.set(repoRoot, lockRegistry); + return lockRegistry; + } + + getLockRegistryForRepo(repoRoot) { + return this.lockRegistryByRepoRoot.get(repoRoot) || this.readLockRegistryForRepo(repoRoot); + } + + refreshLockRegistryForFile(filePath) { + this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); + } + async getChildren(element) { if (element instanceof RepoItem) { const sectionItems = [ diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 6cfcbaa..2463301 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1460,6 +1460,180 @@ test('active-agents extension asks for a session before committing', async () => } }); +test('active-agents extension decorates sessions and repo changes from the lock registry', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-decorations-')); + initGitRepo(tempRoot); + fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\n', 'utf8'); + runGit(tempRoot, ['add', 'root-file.txt']); + runGit(tempRoot, ['commit', '-m', 'baseline']); + fs.writeFileSync(path.join(tempRoot, 'root-file.txt'), 'base\nchanged\n', 'utf8'); + + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-worktree-')); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const branch = 'agent/codex/live-task'; + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync( + sessionPath, + `${JSON.stringify(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch, + taskName: 'live-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + }), null, 2)}\n`, + 'utf8', + ); + + const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, `${JSON.stringify({ + locks: { + 'owned-file.txt': { + branch, + claimed_at: '2026-04-22T08:55:00.000Z', + allow_delete: false, + }, + 'root-file.txt': { + branch: 'agent/codex/other-task', + claimed_at: '2026-04-22T08:56:00.000Z', + allow_delete: false, + }, + }, + }, null, 2)}\n`, 'utf8'); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + const provider = registrations.providers[0].provider; + const [repoItem] = await provider.getChildren(); + const [agentsSection, changesSection] = await provider.getChildren(repoItem); + const [thinkingSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(thinkingSection); + assert.equal(sessionItem.label, `${path.basename(worktreePath)} 🔒 1`); + assert.match(sessionItem.tooltip, /Locks 1/); + + const [changeItem] = await provider.getChildren(changesSection); + assert.equal(changeItem.label, 'root-file.txt'); + assert.equal(changeItem.iconPath.id, 'warning'); + assert.match(changeItem.tooltip, /Locked by agent\/codex\/other-task/); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension re-reads lock state on watcher events instead of every tree load', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-watch-')); + const branch = 'agent/codex/live-task'; + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-lock-watch-worktree-')); + initGitRepo(worktreePath); + fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); + runGit(worktreePath, ['add', 'tracked.txt']); + runGit(worktreePath, ['commit', '-m', 'baseline']); + + const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync( + sessionPath, + `${JSON.stringify(sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch, + taskName: 'live-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + }), null, 2)}\n`, + 'utf8', + ); + + const lockPath = path.join(tempRoot, '.omx', 'state', 'agent-file-locks.json'); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, `${JSON.stringify({ + locks: { + 'owned-file.txt': { + branch, + claimed_at: '2026-04-22T08:57:00.000Z', + allow_delete: false, + }, + }, + }, null, 2)}\n`, 'utf8'); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + const originalReadFileSync = fs.readFileSync; + let lockReadCount = 0; + fs.readFileSync = function patchedReadFileSync(filePath, ...args) { + if (path.resolve(String(filePath)) === lockPath) { + lockReadCount += 1; + } + return originalReadFileSync.call(this, filePath, ...args); + }; + + try { + extension.activate(context); + + const provider = registrations.providers[0].provider; + const lockWatcher = registrations.watchers.find((watcher) => watcher.pattern === '**/.omx/state/agent-file-locks.json'); + assert.ok(lockWatcher, 'expected lock watcher registration'); + + const [repoItem] = await provider.getChildren(); + const [agentsSection] = await provider.getChildren(repoItem); + const [thinkingSection] = await provider.getChildren(agentsSection); + const [sessionItem] = await provider.getChildren(thinkingSection); + assert.equal(sessionItem.label, `${path.basename(worktreePath)} 🔒 1`); + assert.equal(lockReadCount, 1); + + await provider.getChildren(); + assert.equal(lockReadCount, 1); + + fs.writeFileSync(lockPath, `${JSON.stringify({ + locks: { + 'owned-file.txt': { + branch, + claimed_at: '2026-04-22T08:57:00.000Z', + allow_delete: false, + }, + 'second-owned-file.txt': { + branch, + claimed_at: '2026-04-22T08:58:00.000Z', + allow_delete: false, + }, + }, + }, null, 2)}\n`, 'utf8'); + lockWatcher.fireChange({ fsPath: lockPath }); + assert.equal(lockReadCount, 2); + + const [updatedRepoItem] = await provider.getChildren(); + const [updatedAgentsSection] = await provider.getChildren(updatedRepoItem); + const [updatedThinkingSection] = await provider.getChildren(updatedAgentsSection); + const [updatedSessionItem] = await provider.getChildren(updatedThinkingSection); + assert.equal(updatedSessionItem.label, `${path.basename(worktreePath)} 🔒 2`); + + await provider.getChildren(); + assert.equal(lockReadCount, 2); + } finally { + fs.readFileSync = originalReadFileSync; + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } + } +}); + test('active-agents extension launches finish and sync commands in session terminals', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inline-actions-')); const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-inline-worktree-')); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 4f99bf1..9a8d554 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -913,6 +913,20 @@ class ActiveAgentsProvider { this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); } + readLockRegistryForRepo(repoRoot) { + const lockRegistry = readLockRegistry(repoRoot); + this.lockRegistryByRepoRoot.set(repoRoot, lockRegistry); + return lockRegistry; + } + + getLockRegistryForRepo(repoRoot) { + return this.lockRegistryByRepoRoot.get(repoRoot) || this.readLockRegistryForRepo(repoRoot); + } + + refreshLockRegistryForFile(filePath) { + this.readLockRegistryForRepo(repoRootFromLockFile(filePath)); + } + async getChildren(element) { if (element instanceof RepoItem) { const sectionItems = [