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,16 @@
# Patch Active Agents empty-list fallback

## Problem

The VS Code Active Agents view can show `No active Guardex agents` even when `gx branch start` has created a managed sandbox under `.omx/agent-worktrees/`. The current fallback only sees launcher session JSON files or `AGENT.lock` telemetry, so plain Guardex worktrees stay invisible.

## Scope

- Teach the session reader to synthesize rows from real managed `agent/*` git worktrees when no launcher state or `AGENT.lock` exists.
- Keep richer `AGENT.lock` telemetry preferred when present.
- Add focused regression coverage for session discovery and the SCM tree view.

## Out Of Scope

- New VS Code commands or layout redesigns.
- Changes to branch creation, launcher heartbeat emission, or lock ownership semantics.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## ADDED Requirements

### Requirement: Active Agents shows managed sandboxes without launcher telemetry

The GitGuardex Active Agents VS Code companion SHALL surface real managed `agent/*` git worktrees under `.omx/agent-worktrees/` and `.omc/agent-worktrees/` even when no `.omx/state/active-sessions/*.json` launcher record and no worktree `AGENT.lock` telemetry exists.

#### Scenario: Plain managed worktree is visible

- **GIVEN** a repository has a managed worktree under `.omx/agent-worktrees/`
- **AND** that worktree is checked out on an `agent/*` branch
- **AND** there is no active-session JSON file or worktree `AGENT.lock`
- **WHEN** the Active Agents view refreshes
- **THEN** the view shows that worktree as an active agent row
- **AND** dirty files inside the worktree drive the row activity state and changed-file count

#### Scenario: Telemetry remains preferred

- **GIVEN** a managed worktree has valid `AGENT.lock` telemetry
- **WHEN** the Active Agents view refreshes
- **THEN** the view uses the richer telemetry-backed session data for that worktree instead of the plain fallback row
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Tasks

Handoff: change=`agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32`; branch=`agent/codex/patch-vscode-active-agents-empty-list-2026-04-23-11-32`; scope=`vscode/guardex-active-agents/session-schema.js`, `templates/vscode/guardex-active-agents/session-schema.js`, `test/vscode-active-agents-session-state.test.js`; action=`patch Active Agents discovery so plain managed worktrees show in the VS Code SCM view, verify, then finish via PR merge cleanup`.

## 1. Spec

- [x] 1.1 Capture the empty-list fallback failure and acceptance criteria.

## 2. Tests

- [x] 2.1 Add session-schema regression coverage for plain managed worktrees with no launcher JSON and no `AGENT.lock`.
- [x] 2.2 Add extension-view regression coverage proving workspace fallback renders those worktrees instead of the empty state.

## 3. Implementation

- [x] 3.1 Add managed-worktree session synthesis while keeping `AGENT.lock` telemetry preferred.
- [x] 3.2 Mirror the session schema change into the install template.
- [x] 3.3 Bump live/template Active Agents extension manifests from `0.0.7` to `0.0.8` so local VS Code auto-update can supersede the installed build.

## 4. Verification

- [x] 4.1 Run focused Active Agents tests. Result: `node --test test/vscode-active-agents-session-state.test.js` passed, 39/39; `node --test test/metadata.test.js` passed, 18/18.
- [x] 4.2 Validate OpenSpec specs. Result: `openspec validate agent-codex-patch-vscode-active-agents-empty-list-2026-04-23-11-32 --strict` passed; `openspec validate --specs` returned no main-spec items.

## 5. Cleanup

- [ ] 5.1 Commit, push, open PR, wait for `MERGED`, and prune the sandbox with `gx branch finish --branch "agent/codex/patch-vscode-active-agents-empty-list-2026-04-23-11-32" --base main --via-pr --wait-for-merge --cleanup`.
- [ ] 5.2 Record PR URL and final `MERGED` cleanup evidence here.
2 changes: 1 addition & 1 deletion templates/vscode/guardex-active-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "GitGuardex Active Agents",
"description": "Shows live Guardex sandbox sessions and repo changes inside VS Code Source Control.",
"publisher": "recodeee",
"version": "0.0.7",
"version": "0.0.8",
"license": "MIT",
"icon": "icon.png",
"engines": {
Expand Down
104 changes: 103 additions & 1 deletion templates/vscode/guardex-active-agents/session-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,23 @@ function deriveAgentNameFromBranch(branch) {
return 'agent';
}

function isManagedAgentBranch(branch) {
return toNonEmptyString(branch).startsWith('agent/');
}

function deriveManagedWorktreeStartedAt(worktreePath, now = Date.now()) {
try {
const stats = fs.statSync(worktreePath);
if (Number.isFinite(stats.mtimeMs)) {
return new Date(stats.mtimeMs).toISOString();
}
} catch (_error) {
// Directory mtime is best-effort context only; fall back to current scan time.
}

return new Date(now).toISOString();
}

function flattenTelemetrySnapshotSessions(lockPayload) {
const flattened = [];
const snapshots = Array.isArray(lockPayload?.snapshots) ? lockPayload.snapshots : [];
Expand Down Expand Up @@ -962,6 +979,48 @@ function buildWorktreeLockSession(repoRoot, worktreePath, lockPayload, options =
return session;
}

function buildManagedWorktreeSession(repoRoot, worktreePath, options = {}) {
const now = options.now || Date.now();
const branch = readWorktreeBranch(worktreePath);
if (!branch || branch === 'HEAD' || !isManagedAgentBranch(branch)) {
return null;
}

const label = deriveSessionLabel(branch, worktreePath);
const startedAt = deriveManagedWorktreeStartedAt(worktreePath, now);
const session = {
schemaVersion: SESSION_SCHEMA_VERSION,
repoRoot: path.resolve(repoRoot),
branch,
taskName: label,
latestTaskPreview: '',
agentName: deriveAgentNameFromBranch(branch),
worktreePath: path.resolve(worktreePath),
pid: null,
cliName: 'gx',
taskMode: '',
openspecTier: '',
taskRoutingReason: '',
startedAt,
lastHeartbeatAt: '',
state: '',
filePath: path.join(worktreePath, '.git'),
label,
changedPaths: [],
worktreeChangedPaths: [],
sourceKind: 'managed-worktree',
telemetryUpdatedAt: '',
telemetrySource: 'managed-worktree',
lockSnapshotCount: 0,
lockSessionCount: 0,
collaboration: false,
};

session.elapsedLabel = formatElapsedFrom(session.startedAt, now);
Object.assign(session, deriveSessionActivity(session, { now }));
return session;
}

function readWorktreeLockSessions(repoRoot, options = {}) {
const sessions = [];
for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) {
Expand Down Expand Up @@ -1004,6 +1063,48 @@ function readWorktreeLockSessions(repoRoot, options = {}) {
return sortSessionsByTimestamp(sessions);
}

function readManagedWorktreeSessions(repoRoot, options = {}) {
const lockSessions = readWorktreeLockSessions(repoRoot, options);
const lockSessionsByWorktree = new Map(
lockSessions.map((session) => [path.resolve(session.worktreePath), session]),
);
const sessions = [];

for (const managedRoot of resolveManagedWorktreeRoots(repoRoot)) {
if (!fs.existsSync(managedRoot)) {
continue;
}

let entries;
try {
entries = fs.readdirSync(managedRoot, { withFileTypes: true });
} catch (_error) {
continue;
}

for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}

const worktreePath = path.join(managedRoot, entry.name);
const worktreeKey = path.resolve(worktreePath);
const lockSession = lockSessionsByWorktree.get(worktreeKey);
if (lockSession) {
sessions.push(lockSession);
continue;
}

const managedSession = buildManagedWorktreeSession(repoRoot, worktreePath, options);
if (managedSession) {
sessions.push(managedSession);
}
}
}

return sortSessionsByTimestamp(sessions);
}

function mergeSessionSources(primarySessions, lockSessions) {
const lockSessionsByWorktree = new Map(
lockSessions.map((session) => [path.resolve(session.worktreePath), session]),
Expand Down Expand Up @@ -1061,7 +1162,7 @@ function readActiveSessions(repoRoot, options = {}) {

return mergeSessionSources(
sortSessionsByTimestamp(sessionFileSessions),
readWorktreeLockSessions(repoRoot, { now }),
readManagedWorktreeSessions(repoRoot, { now }),
);
}

Expand Down Expand Up @@ -1096,6 +1197,7 @@ module.exports = {
parseRepoChangeLine,
previewChangedPaths,
readActiveSessions,
readManagedWorktreeSessions,
readWorktreeLockSessions,
readRepoChanges,
deriveRepoChangeStatus,
Expand Down
69 changes: 69 additions & 0 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,31 @@ test('session-schema falls back to managed worktree AGENT.lock telemetry when la
assert.equal(session.telemetryUpdatedAt, '2026-04-22T08:56:00.000Z');
});

test('session-schema falls back to plain managed worktrees when launcher state and AGENT.lock are absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-managed-fallback-'));
const worktreePath = path.join(
tempRoot,
'.omx',
'agent-worktrees',
'agent__codex__plain-visible-task',
);
initGitRepo(worktreePath);
runGit(worktreePath, ['checkout', '-b', 'agent/codex/plain-visible-task']);
fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8');
runGit(worktreePath, ['add', 'tracked.txt']);
runGit(worktreePath, ['commit', '-m', 'baseline']);
fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8');

const [session] = sessionSchema.readActiveSessions(tempRoot);
assert.equal(session.sourceKind, 'managed-worktree');
assert.equal(session.branch, 'agent/codex/plain-visible-task');
assert.equal(session.agentName, 'codex');
assert.equal(session.taskName, 'agent__codex__plain-visible-task');
assert.equal(session.activityKind, 'working');
assert.equal(session.activityCountLabel, '1 file');
assert.equal(session.telemetrySource, 'managed-worktree');
});

test('session-schema prefers live worktree telemetry over a dead launcher record for the same worktree', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-active-session-lock-prefer-'));
const worktreePath = path.join(
Expand Down Expand Up @@ -1725,6 +1750,50 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa
}
});

test('active-agents extension surfaces plain managed worktrees from workspace fallback', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-managed-worktree-view-'));
initGitRepo(tempRoot);

const worktreePath = path.join(
tempRoot,
'.omx',
'agent-worktrees',
'agent__codex__plain-visible-task',
);
initGitRepo(worktreePath);
runGit(worktreePath, ['checkout', '-b', 'agent/codex/plain-visible-task']);
fs.mkdirSync(path.join(worktreePath, 'src'), { recursive: true });
fs.writeFileSync(path.join(worktreePath, 'src', 'live.js'), 'base\n', 'utf8');
runGit(worktreePath, ['add', 'src/live.js']);
runGit(worktreePath, ['commit', '-m', 'baseline']);
fs.writeFileSync(path.join(worktreePath, 'src', 'live.js'), 'base\nchanged\n', 'utf8');

const { registrations, vscode } = createMockVscode(tempRoot);
vscode.workspace.findFiles = async () => [];
const extension = loadExtensionWithMockVscode(vscode);
const context = { subscriptions: [] };

extension.activate(context);
await flushAsyncWork();

const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
assert.equal(repoItem.description, '1 active · 1 working · 1 changed');

const [agentsSection] = await provider.getChildren(repoItem);
const [workingSection] = await provider.getChildren(agentsSection);
const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection);
assert.equal(workingSection.label, 'WORKING NOW');
assert.equal(worktreeItem.label, path.basename(worktreePath));
assert.equal(sessionItem.label, 'agent/codex/plain-visible-task');
assert.match(sessionItem.description, /^working · 1 file · /);
assert.match(sessionItem.tooltip, /Started /);

for (const subscription of context.subscriptions) {
subscription.dispose?.();
}
});

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);
Expand Down
2 changes: 1 addition & 1 deletion vscode/guardex-active-agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "GitGuardex Active Agents",
"description": "Shows live Guardex sandbox sessions and repo changes inside VS Code Source Control.",
"publisher": "recodeee",
"version": "0.0.7",
"version": "0.0.8",
"license": "MIT",
"icon": "icon.png",
"engines": {
Expand Down
Loading