Skip to content

Commit fad45e8

Browse files
NagyViktNagyVikt
andauthored
Clear stale Active Agents rows safely (#393)
Stalled and dead Active Agents rows were previously stuck with only the live Stop action, which expects a running pid or terminal. This change adds a separate Dismiss action that deletes the matching active-session record, keeps the existing Stop flow for live sessions, mirrors the behavior into the shipped template, and bumps the extension manifest version so installs can pick up the new surface. Constraint: Stop must remain process-oriented for live sessions; stale-row cleanup must not pretend to kill a process Rejected: Reuse Stop for stale rows | mixes process control with sidebar-only cleanup and still wants a pid-oriented flow Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep session-only cleanup on the dismiss path and preserve gx stop semantics for live terminals or pids Tested: node --test test/vscode-active-agents-session-state.test.js Not-tested: Manual VS Code sidebar interaction in a live editor window Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent f0ee72a commit fad45e8

7 files changed

Lines changed: 289 additions & 16 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-04-23
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# agent-codex-dismiss-stale-active-session-2026-04-23-18-29 (minimal / T1)
2+
3+
Branch: `agent/codex/dismiss-stale-active-session-2026-04-23-18-29`
4+
5+
Describe the change in a sentence or two. Commit message is the spec of record.
6+
7+
## Handoff
8+
9+
- Handoff: change=`agent-codex-dismiss-stale-active-session-2026-04-23-18-29`; branch=`agent/codex/dismiss-stale-active-session-2026-04-23-18-29`; scope=`Active Agents dismiss action for stalled/dead rows, template parity, manifest bump, focused extension tests`; action=`continue this sandbox, add a separate Dismiss action that removes stale active-session records without reusing Stop, then verify and finish cleanup after the earlier usage-limit takeover`.
10+
- Copy prompt: Continue `agent-codex-dismiss-stale-active-session-2026-04-23-18-29` on branch `agent/codex/dismiss-stale-active-session-2026-04-23-18-29`. Work inside the existing sandbox, review `openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/dismiss-stale-active-session-2026-04-23-18-29 --base main --via-pr --wait-for-merge --cleanup`.
11+
- Result: added a separate `Dismiss` action for `stalled`/`dead` Active Agents rows, deleting the matching `.omx/state/active-sessions/*.json` record without reusing the live `Stop` flow; verified with `node --test test/vscode-active-agents-session-state.test.js` (`54/54`).
12+
13+
## Cleanup
14+
15+
- [ ] Run: `gx branch finish --branch agent/codex/dismiss-stale-active-session-2026-04-23-18-29 --base main --via-pr --wait-for-merge --cleanup`
16+
- [ ] Record PR URL + `MERGED` state in the completion handoff.
17+
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).

templates/vscode/guardex-active-agents/extension.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ const path = require('node:path');
33
const cp = require('node:child_process');
44
const vscode = require('vscode');
55
const {
6+
clearWorktreeActivityCache,
67
formatElapsedFrom,
78
readActiveSessions,
89
readRepoChanges,
910
readSessionInspectData,
1011
sanitizeBranchForFile,
12+
sessionFilePathForBranch,
1113
} = require('./session-schema.js');
1214

1315
const SESSION_DECORATION_SCHEME = 'gitguardex-agent';
@@ -65,6 +67,7 @@ const SESSION_ACTIVITY_ICON_IDS = {
6567
stalled: 'clock',
6668
dead: 'error',
6769
};
70+
const DISMISSABLE_SESSION_ACTIVITY_KINDS = new Set(['stalled', 'dead']);
6871
const SESSION_PROVIDER_BRANDS = {
6972
openai: {
7073
id: 'openai',
@@ -1289,7 +1292,7 @@ class SessionItem extends vscode.TreeItem {
12891292
: buildSessionCardDescription(session);
12901293
this.tooltip = buildSessionTooltip(session, this.description);
12911294
this.iconPath = themeIcon(resolveSessionActivityIconId(session.activityKind));
1292-
this.contextValue = 'gitguardex.session';
1295+
this.contextValue = sessionContextValue(session);
12931296
this.command = {
12941297
command: 'gitguardex.activeAgents.openWorktree',
12951298
title: 'Open Agent Worktree',
@@ -1298,6 +1301,35 @@ class SessionItem extends vscode.TreeItem {
12981301
}
12991302
}
13001303

1304+
function sessionContextValue(session) {
1305+
const activityKind = typeof session?.activityKind === 'string' ? session.activityKind.trim() : '';
1306+
return activityKind
1307+
? `gitguardex.session.${activityKind}`
1308+
: 'gitguardex.session';
1309+
}
1310+
1311+
function canDismissSession(session) {
1312+
return DISMISSABLE_SESSION_ACTIVITY_KINDS.has(session?.activityKind);
1313+
}
1314+
1315+
function buildDismissSessionDetail(session, statePath) {
1316+
const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : '';
1317+
const relativeStatePath = repoRoot
1318+
? path.relative(repoRoot, statePath) || path.basename(statePath)
1319+
: path.basename(statePath);
1320+
const detailParts = [
1321+
`Remove ${relativeStatePath} and hide this session from Active Agents.`,
1322+
];
1323+
1324+
if (session?.activityKind === 'stalled') {
1325+
detailParts.push('This dismisses the stale sidebar row only; use Stop if you want to interrupt a live agent.');
1326+
} else {
1327+
detailParts.push('This clears the stale session record from the sidebar.');
1328+
}
1329+
1330+
return detailParts.join(' ');
1331+
}
1332+
13011333
class FolderItem extends vscode.TreeItem {
13021334
constructor(label, relativePath, items, options = {}) {
13031335
super(
@@ -1845,6 +1877,51 @@ async function stopSession(session, refresh) {
18451877
}
18461878
}
18471879

1880+
async function dismissSession(session, refresh) {
1881+
if (!canDismissSession(session)) {
1882+
showSessionMessage('Only stalled or dead sessions can be dismissed.');
1883+
return;
1884+
}
1885+
1886+
const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : '';
1887+
if (!repoRoot) {
1888+
showSessionMessage('Cannot dismiss session: missing repo root.');
1889+
return;
1890+
}
1891+
if (!session?.branch) {
1892+
showSessionMessage('Cannot dismiss session: missing branch name.');
1893+
return;
1894+
}
1895+
1896+
const statePath = sessionFilePathForBranch(repoRoot, session.branch);
1897+
if (!fs.existsSync(statePath)) {
1898+
clearWorktreeActivityCache(session.worktreePath);
1899+
refresh();
1900+
showSessionMessage(`Session record already gone for ${sessionDisplayLabel(session)}.`);
1901+
return;
1902+
}
1903+
1904+
const confirmed = await vscode.window.showWarningMessage(
1905+
`Dismiss ${sessionDisplayLabel(session)}?`,
1906+
{
1907+
modal: true,
1908+
detail: buildDismissSessionDetail(session, statePath),
1909+
},
1910+
'Dismiss',
1911+
);
1912+
if (confirmed !== 'Dismiss') {
1913+
return;
1914+
}
1915+
1916+
try {
1917+
fs.unlinkSync(statePath);
1918+
clearWorktreeActivityCache(session.worktreePath);
1919+
refresh();
1920+
} catch (error) {
1921+
showSessionMessage(`Failed to dismiss session ${sessionDisplayLabel(session)}: ${error.message}`);
1922+
}
1923+
}
1924+
18481925
function readGitDirPath(targetPath) {
18491926
const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : '';
18501927
if (!normalizedTargetPath) {
@@ -3358,6 +3435,7 @@ function activate(context) {
33583435
vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession),
33593436
vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession),
33603437
vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)),
3438+
vscode.commands.registerCommand('gitguardex.activeAgents.dismissSession', (session) => dismissSession(session, refresh)),
33613439
vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged),
33623440
activeSessionsWatcher,
33633441
lockWatcher,

templates/vscode/guardex-active-agents/package.json

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "GitGuardex Active Agents",
44
"description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.",
55
"publisher": "recodeee",
6-
"version": "0.0.17",
6+
"version": "0.0.18",
77
"license": "MIT",
88
"icon": "icon.png",
99
"engines": {
@@ -65,6 +65,11 @@
6565
"title": "Stop",
6666
"icon": "$(debug-stop)"
6767
},
68+
{
69+
"command": "gitguardex.activeAgents.dismissSession",
70+
"title": "Dismiss",
71+
"icon": "$(trash)"
72+
},
6873
{
6974
"command": "gitguardex.activeAgents.showSessionTerminal",
7075
"title": "Show Terminal",
@@ -125,32 +130,37 @@
125130
"view/item/context": [
126131
{
127132
"command": "gitguardex.activeAgents.openWorktree",
128-
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
133+
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
129134
"group": "inline"
130135
},
131136
{
132137
"command": "gitguardex.activeAgents.inspect",
133-
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
138+
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
134139
"group": "inline"
135140
},
136141
{
137142
"command": "gitguardex.activeAgents.showSessionTerminal",
138-
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
143+
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
139144
"group": "inline"
140145
},
141146
{
142147
"command": "gitguardex.activeAgents.finishSession",
143-
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
148+
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
144149
"group": "inline"
145150
},
146151
{
147152
"command": "gitguardex.activeAgents.syncSession",
148-
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
153+
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
149154
"group": "inline"
150155
},
151156
{
152157
"command": "gitguardex.activeAgents.stopSession",
153-
"when": "view == gitguardex.activeAgents && viewItem == gitguardex.session",
158+
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/",
159+
"group": "inline"
160+
},
161+
{
162+
"command": "gitguardex.activeAgents.dismissSession",
163+
"when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/",
154164
"group": "inline"
155165
}
156166
]

test/vscode-active-agents-session-state.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,34 @@ test('active-agents manifest contributes restart actions for extension managemen
13541354
);
13551355
});
13561356

1357+
test('active-agents manifest contributes dismiss only for stalled and dead session rows', () => {
1358+
const manifest = readExtensionManifest();
1359+
const templateManifest = readExtensionManifest(templateExtensionManifestPath);
1360+
1361+
const dismissCommand = manifest.contributes.commands.find(
1362+
(entry) => entry.command === 'gitguardex.activeAgents.dismissSession',
1363+
);
1364+
assert.deepEqual(dismissCommand, {
1365+
command: 'gitguardex.activeAgents.dismissSession',
1366+
title: 'Dismiss',
1367+
icon: '$(trash)',
1368+
});
1369+
1370+
const dismissMenuAction = manifest.contributes.menus['view/item/context'].find(
1371+
(entry) => entry.command === 'gitguardex.activeAgents.dismissSession',
1372+
);
1373+
assert.deepEqual(dismissMenuAction, {
1374+
command: 'gitguardex.activeAgents.dismissSession',
1375+
when: 'view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/',
1376+
group: 'inline',
1377+
});
1378+
1379+
assert.deepEqual(
1380+
manifest.contributes.menus['view/item/context'],
1381+
templateManifest.contributes.menus['view/item/context'],
1382+
);
1383+
});
1384+
13571385
test('active-agents extension auto-installs a newer workspace build and offers reload', async () => {
13581386
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-autoupdate-'));
13591387
const repoManifest = {
@@ -2836,12 +2864,15 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s
28362864
assert.equal(blockedItem.iconPath.id, 'warning');
28372865
assert.match(workingItem.description, /^Working: codex · via OpenAI · 1 changed file/);
28382866
assert.equal(workingItem.iconPath.id, 'loading~spin');
2867+
assert.equal(workingItem.contextValue, 'gitguardex.session.working');
28392868
assert.match(idleItem.description, /^Idle: codex · via OpenAI/);
28402869
assert.equal(idleItem.iconPath.id, 'comment-discussion');
28412870
assert.match(stalledItem.description, /^Stale: codex · via OpenAI/);
28422871
assert.equal(stalledItem.iconPath.id, 'clock');
2872+
assert.equal(stalledItem.contextValue, 'gitguardex.session.stalled');
28432873
assert.match(deadItem.description, /^Dead: codex · via OpenAI/);
28442874
assert.equal(deadItem.iconPath.id, 'error');
2875+
assert.equal(deadItem.contextValue, 'gitguardex.session.dead');
28452876
assert.deepEqual(registrations.treeViews[0].badge, {
28462877
value: 5,
28472878
tooltip: repoItem.description,
@@ -3515,6 +3546,53 @@ test('active-agents extension confirms stop and routes through gx agents stop --
35153546
}
35163547
});
35173548

3549+
test('active-agents extension dismisses stalled session rows by deleting the matching active-session record', async () => {
3550+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dismiss-session-'));
3551+
const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dismiss-worktree-'));
3552+
const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({
3553+
repoRoot: tempRoot,
3554+
branch: 'agent/codex/stalled-task',
3555+
taskName: 'stalled-task',
3556+
agentName: 'codex',
3557+
worktreePath,
3558+
pid: 4242,
3559+
cliName: 'codex',
3560+
}));
3561+
const { registrations, vscode } = createMockVscode(tempRoot);
3562+
const extension = loadExtensionWithMockVscode(vscode);
3563+
const context = { subscriptions: [] };
3564+
3565+
vscode.window.showWarningMessage = async (...args) => {
3566+
registrations.warningMessages.push(args);
3567+
return 'Dismiss';
3568+
};
3569+
3570+
extension.activate(context);
3571+
const provider = registrations.providers[0].provider;
3572+
await flushAsyncWork();
3573+
provider.onDidChangeTreeDataEmitter.fireCount = 0;
3574+
3575+
await registrations.commands.get('gitguardex.activeAgents.dismissSession')({
3576+
label: 'stalled-task',
3577+
branch: 'agent/codex/stalled-task',
3578+
activityKind: 'stalled',
3579+
repoRoot: tempRoot,
3580+
worktreePath,
3581+
});
3582+
await flushAsyncWork();
3583+
3584+
assert.equal(fs.existsSync(sessionPath), false);
3585+
assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1);
3586+
assert.equal(registrations.warningMessages.length, 1);
3587+
assert.match(registrations.warningMessages[0][0], /Dismiss stalled-task\?/);
3588+
assert.match(registrations.warningMessages[0][1].detail, /\.omx[\/\\]state[\/\\]active-sessions/);
3589+
assert.match(registrations.warningMessages[0][1].detail, /stale sidebar row only/);
3590+
3591+
for (const subscription of context.subscriptions) {
3592+
subscription.dispose?.();
3593+
}
3594+
});
3595+
35183596
test('active-agents extension uses bundled OpenSpec icons in Active Agents tree nodes', async () => {
35193597
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-openspec-icons-'));
35203598
initGitRepo(tempRoot);

0 commit comments

Comments
 (0)