Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 `🔒 <count>`
- **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`
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
174 changes: 174 additions & 0 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down
14 changes: 14 additions & 0 deletions vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down