From 4fcd74501a35690e514dcde5f2e35ffd9b79b412 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 18:56:57 +0200 Subject: [PATCH] Clear stale Active Agents rows safely 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 --- .../.openspec.yaml | 2 + .../notes.md | 17 ++++ .../vscode/guardex-active-agents/extension.js | 80 ++++++++++++++++++- .../vscode/guardex-active-agents/package.json | 24 ++++-- ...vscode-active-agents-session-state.test.js | 78 ++++++++++++++++++ vscode/guardex-active-agents/extension.js | 80 ++++++++++++++++++- vscode/guardex-active-agents/package.json | 24 ++++-- 7 files changed, 289 insertions(+), 16 deletions(-) create mode 100644 openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/.openspec.yaml create mode 100644 openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/notes.md diff --git a/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/.openspec.yaml b/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/.openspec.yaml new file mode 100644 index 0000000..8b394c6 --- /dev/null +++ b/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-23 diff --git a/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/notes.md b/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/notes.md new file mode 100644 index 0000000..8c42ab0 --- /dev/null +++ b/openspec/changes/agent-codex-dismiss-stale-active-session-2026-04-23-18-29/notes.md @@ -0,0 +1,17 @@ +# agent-codex-dismiss-stale-active-session-2026-04-23-18-29 (minimal / T1) + +Branch: `agent/codex/dismiss-stale-active-session-2026-04-23-18-29` + +Describe the change in a sentence or two. Commit message is the spec of record. + +## Handoff + +- 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`. +- 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`. +- 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`). + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/dismiss-stale-active-session-2026-04-23-18-29 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 3c4f07d..6546080 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -3,11 +3,13 @@ const path = require('node:path'); const cp = require('node:child_process'); const vscode = require('vscode'); const { + clearWorktreeActivityCache, formatElapsedFrom, readActiveSessions, readRepoChanges, readSessionInspectData, sanitizeBranchForFile, + sessionFilePathForBranch, } = require('./session-schema.js'); const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; @@ -65,6 +67,7 @@ const SESSION_ACTIVITY_ICON_IDS = { stalled: 'clock', dead: 'error', }; +const DISMISSABLE_SESSION_ACTIVITY_KINDS = new Set(['stalled', 'dead']); const SESSION_PROVIDER_BRANDS = { openai: { id: 'openai', @@ -1289,7 +1292,7 @@ class SessionItem extends vscode.TreeItem { : buildSessionCardDescription(session); this.tooltip = buildSessionTooltip(session, this.description); this.iconPath = themeIcon(resolveSessionActivityIconId(session.activityKind)); - this.contextValue = 'gitguardex.session'; + this.contextValue = sessionContextValue(session); this.command = { command: 'gitguardex.activeAgents.openWorktree', title: 'Open Agent Worktree', @@ -1298,6 +1301,35 @@ class SessionItem extends vscode.TreeItem { } } +function sessionContextValue(session) { + const activityKind = typeof session?.activityKind === 'string' ? session.activityKind.trim() : ''; + return activityKind + ? `gitguardex.session.${activityKind}` + : 'gitguardex.session'; +} + +function canDismissSession(session) { + return DISMISSABLE_SESSION_ACTIVITY_KINDS.has(session?.activityKind); +} + +function buildDismissSessionDetail(session, statePath) { + const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; + const relativeStatePath = repoRoot + ? path.relative(repoRoot, statePath) || path.basename(statePath) + : path.basename(statePath); + const detailParts = [ + `Remove ${relativeStatePath} and hide this session from Active Agents.`, + ]; + + if (session?.activityKind === 'stalled') { + detailParts.push('This dismisses the stale sidebar row only; use Stop if you want to interrupt a live agent.'); + } else { + detailParts.push('This clears the stale session record from the sidebar.'); + } + + return detailParts.join(' '); +} + class FolderItem extends vscode.TreeItem { constructor(label, relativePath, items, options = {}) { super( @@ -1845,6 +1877,51 @@ async function stopSession(session, refresh) { } } +async function dismissSession(session, refresh) { + if (!canDismissSession(session)) { + showSessionMessage('Only stalled or dead sessions can be dismissed.'); + return; + } + + const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; + if (!repoRoot) { + showSessionMessage('Cannot dismiss session: missing repo root.'); + return; + } + if (!session?.branch) { + showSessionMessage('Cannot dismiss session: missing branch name.'); + return; + } + + const statePath = sessionFilePathForBranch(repoRoot, session.branch); + if (!fs.existsSync(statePath)) { + clearWorktreeActivityCache(session.worktreePath); + refresh(); + showSessionMessage(`Session record already gone for ${sessionDisplayLabel(session)}.`); + return; + } + + const confirmed = await vscode.window.showWarningMessage( + `Dismiss ${sessionDisplayLabel(session)}?`, + { + modal: true, + detail: buildDismissSessionDetail(session, statePath), + }, + 'Dismiss', + ); + if (confirmed !== 'Dismiss') { + return; + } + + try { + fs.unlinkSync(statePath); + clearWorktreeActivityCache(session.worktreePath); + refresh(); + } catch (error) { + showSessionMessage(`Failed to dismiss session ${sessionDisplayLabel(session)}: ${error.message}`); + } +} + function readGitDirPath(targetPath) { const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : ''; if (!normalizedTargetPath) { @@ -3358,6 +3435,7 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession), vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), + vscode.commands.registerCommand('gitguardex.activeAgents.dismissSession', (session) => dismissSession(session, refresh)), vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged), activeSessionsWatcher, lockWatcher, diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 9b2aeb6..39bf80f 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", "publisher": "recodeee", - "version": "0.0.17", + "version": "0.0.18", "license": "MIT", "icon": "icon.png", "engines": { @@ -65,6 +65,11 @@ "title": "Stop", "icon": "$(debug-stop)" }, + { + "command": "gitguardex.activeAgents.dismissSession", + "title": "Dismiss", + "icon": "$(trash)" + }, { "command": "gitguardex.activeAgents.showSessionTerminal", "title": "Show Terminal", @@ -125,32 +130,37 @@ "view/item/context": [ { "command": "gitguardex.activeAgents.openWorktree", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.inspect", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.showSessionTerminal", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.finishSession", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.syncSession", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.stopSession", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", + "group": "inline" + }, + { + "command": "gitguardex.activeAgents.dismissSession", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/", "group": "inline" } ] diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 2b0e2b3..c1340b9 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1354,6 +1354,34 @@ test('active-agents manifest contributes restart actions for extension managemen ); }); +test('active-agents manifest contributes dismiss only for stalled and dead session rows', () => { + const manifest = readExtensionManifest(); + const templateManifest = readExtensionManifest(templateExtensionManifestPath); + + const dismissCommand = manifest.contributes.commands.find( + (entry) => entry.command === 'gitguardex.activeAgents.dismissSession', + ); + assert.deepEqual(dismissCommand, { + command: 'gitguardex.activeAgents.dismissSession', + title: 'Dismiss', + icon: '$(trash)', + }); + + const dismissMenuAction = manifest.contributes.menus['view/item/context'].find( + (entry) => entry.command === 'gitguardex.activeAgents.dismissSession', + ); + assert.deepEqual(dismissMenuAction, { + command: 'gitguardex.activeAgents.dismissSession', + when: 'view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/', + group: 'inline', + }); + + assert.deepEqual( + manifest.contributes.menus['view/item/context'], + templateManifest.contributes.menus['view/item/context'], + ); +}); + test('active-agents extension auto-installs a newer workspace build and offers reload', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-autoupdate-')); const repoManifest = { @@ -2836,12 +2864,15 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s assert.equal(blockedItem.iconPath.id, 'warning'); assert.match(workingItem.description, /^Working: codex · via OpenAI · 1 changed file/); assert.equal(workingItem.iconPath.id, 'loading~spin'); + assert.equal(workingItem.contextValue, 'gitguardex.session.working'); assert.match(idleItem.description, /^Idle: codex · via OpenAI/); assert.equal(idleItem.iconPath.id, 'comment-discussion'); assert.match(stalledItem.description, /^Stale: codex · via OpenAI/); assert.equal(stalledItem.iconPath.id, 'clock'); + assert.equal(stalledItem.contextValue, 'gitguardex.session.stalled'); assert.match(deadItem.description, /^Dead: codex · via OpenAI/); assert.equal(deadItem.iconPath.id, 'error'); + assert.equal(deadItem.contextValue, 'gitguardex.session.dead'); assert.deepEqual(registrations.treeViews[0].badge, { value: 5, tooltip: repoItem.description, @@ -3515,6 +3546,53 @@ test('active-agents extension confirms stop and routes through gx agents stop -- } }); +test('active-agents extension dismisses stalled session rows by deleting the matching active-session record', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dismiss-session-')); + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-dismiss-worktree-')); + const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/stalled-task', + taskName: 'stalled-task', + agentName: 'codex', + worktreePath, + pid: 4242, + cliName: 'codex', + })); + const { registrations, vscode } = createMockVscode(tempRoot); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + vscode.window.showWarningMessage = async (...args) => { + registrations.warningMessages.push(args); + return 'Dismiss'; + }; + + extension.activate(context); + const provider = registrations.providers[0].provider; + await flushAsyncWork(); + provider.onDidChangeTreeDataEmitter.fireCount = 0; + + await registrations.commands.get('gitguardex.activeAgents.dismissSession')({ + label: 'stalled-task', + branch: 'agent/codex/stalled-task', + activityKind: 'stalled', + repoRoot: tempRoot, + worktreePath, + }); + await flushAsyncWork(); + + assert.equal(fs.existsSync(sessionPath), false); + assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1); + assert.equal(registrations.warningMessages.length, 1); + assert.match(registrations.warningMessages[0][0], /Dismiss stalled-task\?/); + assert.match(registrations.warningMessages[0][1].detail, /\.omx[\/\\]state[\/\\]active-sessions/); + assert.match(registrations.warningMessages[0][1].detail, /stale sidebar row only/); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension uses bundled OpenSpec icons in Active Agents tree nodes', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-openspec-icons-')); initGitRepo(tempRoot); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 3c4f07d..6546080 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -3,11 +3,13 @@ const path = require('node:path'); const cp = require('node:child_process'); const vscode = require('vscode'); const { + clearWorktreeActivityCache, formatElapsedFrom, readActiveSessions, readRepoChanges, readSessionInspectData, sanitizeBranchForFile, + sessionFilePathForBranch, } = require('./session-schema.js'); const SESSION_DECORATION_SCHEME = 'gitguardex-agent'; @@ -65,6 +67,7 @@ const SESSION_ACTIVITY_ICON_IDS = { stalled: 'clock', dead: 'error', }; +const DISMISSABLE_SESSION_ACTIVITY_KINDS = new Set(['stalled', 'dead']); const SESSION_PROVIDER_BRANDS = { openai: { id: 'openai', @@ -1289,7 +1292,7 @@ class SessionItem extends vscode.TreeItem { : buildSessionCardDescription(session); this.tooltip = buildSessionTooltip(session, this.description); this.iconPath = themeIcon(resolveSessionActivityIconId(session.activityKind)); - this.contextValue = 'gitguardex.session'; + this.contextValue = sessionContextValue(session); this.command = { command: 'gitguardex.activeAgents.openWorktree', title: 'Open Agent Worktree', @@ -1298,6 +1301,35 @@ class SessionItem extends vscode.TreeItem { } } +function sessionContextValue(session) { + const activityKind = typeof session?.activityKind === 'string' ? session.activityKind.trim() : ''; + return activityKind + ? `gitguardex.session.${activityKind}` + : 'gitguardex.session'; +} + +function canDismissSession(session) { + return DISMISSABLE_SESSION_ACTIVITY_KINDS.has(session?.activityKind); +} + +function buildDismissSessionDetail(session, statePath) { + const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; + const relativeStatePath = repoRoot + ? path.relative(repoRoot, statePath) || path.basename(statePath) + : path.basename(statePath); + const detailParts = [ + `Remove ${relativeStatePath} and hide this session from Active Agents.`, + ]; + + if (session?.activityKind === 'stalled') { + detailParts.push('This dismisses the stale sidebar row only; use Stop if you want to interrupt a live agent.'); + } else { + detailParts.push('This clears the stale session record from the sidebar.'); + } + + return detailParts.join(' '); +} + class FolderItem extends vscode.TreeItem { constructor(label, relativePath, items, options = {}) { super( @@ -1845,6 +1877,51 @@ async function stopSession(session, refresh) { } } +async function dismissSession(session, refresh) { + if (!canDismissSession(session)) { + showSessionMessage('Only stalled or dead sessions can be dismissed.'); + return; + } + + const repoRoot = typeof session?.repoRoot === 'string' ? session.repoRoot.trim() : ''; + if (!repoRoot) { + showSessionMessage('Cannot dismiss session: missing repo root.'); + return; + } + if (!session?.branch) { + showSessionMessage('Cannot dismiss session: missing branch name.'); + return; + } + + const statePath = sessionFilePathForBranch(repoRoot, session.branch); + if (!fs.existsSync(statePath)) { + clearWorktreeActivityCache(session.worktreePath); + refresh(); + showSessionMessage(`Session record already gone for ${sessionDisplayLabel(session)}.`); + return; + } + + const confirmed = await vscode.window.showWarningMessage( + `Dismiss ${sessionDisplayLabel(session)}?`, + { + modal: true, + detail: buildDismissSessionDetail(session, statePath), + }, + 'Dismiss', + ); + if (confirmed !== 'Dismiss') { + return; + } + + try { + fs.unlinkSync(statePath); + clearWorktreeActivityCache(session.worktreePath); + refresh(); + } catch (error) { + showSessionMessage(`Failed to dismiss session ${sessionDisplayLabel(session)}: ${error.message}`); + } +} + function readGitDirPath(targetPath) { const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : ''; if (!normalizedTargetPath) { @@ -3358,6 +3435,7 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession), vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), + vscode.commands.registerCommand('gitguardex.activeAgents.dismissSession', (session) => dismissSession(session, refresh)), vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged), activeSessionsWatcher, lockWatcher, diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 9b2aeb6..39bf80f 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", "publisher": "recodeee", - "version": "0.0.17", + "version": "0.0.18", "license": "MIT", "icon": "icon.png", "engines": { @@ -65,6 +65,11 @@ "title": "Stop", "icon": "$(debug-stop)" }, + { + "command": "gitguardex.activeAgents.dismissSession", + "title": "Dismiss", + "icon": "$(trash)" + }, { "command": "gitguardex.activeAgents.showSessionTerminal", "title": "Show Terminal", @@ -125,32 +130,37 @@ "view/item/context": [ { "command": "gitguardex.activeAgents.openWorktree", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.inspect", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.showSessionTerminal", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.finishSession", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.syncSession", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", "group": "inline" }, { "command": "gitguardex.activeAgents.stopSession", - "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session(\\.|$)/", + "group": "inline" + }, + { + "command": "gitguardex.activeAgents.dismissSession", + "when": "view == gitguardex.activeAgents && viewItem =~ /^gitguardex\\.session\\.(stalled|dead)$/", "group": "inline" } ]