From 25c68fd87983d0c865d3aabf71d01e99dc638c99 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sat, 25 Apr 2026 16:16:59 +0200 Subject: [PATCH] Clarify dirty finished agents need cleanup Finished Active Agents rows were truthful but ambiguous: users read them as complete even when a dirty worktree still needed cleanup. Keep the underlying activityKind stable, but present that state as Needs cleanup in the sidebar, summaries, tooltips, and session descriptions. Split these rows out of Idle / thinking so the remaining worktree has an obvious cleanup reason. Constraint: activityKind remains finished for compatibility with existing state derivation Constraint: Active Agents template must stay byte-for-byte synced with the canonical extension source Rejected: Hide finished dirty worktrees | hiding would make leftover files less visible and harder to clean Confidence: high Scope-risk: narrow Directive: Do not reintroduce Finished as user-facing wording for dirty idle worktrees without explaining cleanup state Tested: node --test test/vscode-active-agents-session-state.test.js test/metadata.test.js Tested: node --check vscode/guardex-active-agents/extension.js Tested: openspec validate --specs --- .../.openspec.yaml | 2 + .../notes.md | 7 ++ .../vscode/guardex-active-agents/extension.js | 36 +++++++-- .../vscode/guardex-active-agents/package.json | 2 +- ...vscode-active-agents-session-state.test.js | 80 +++++++++++++++++-- vscode/guardex-active-agents/extension.js | 36 +++++++-- vscode/guardex-active-agents/package.json | 2 +- 7 files changed, 141 insertions(+), 24 deletions(-) create mode 100644 openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/.openspec.yaml create mode 100644 openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/notes.md diff --git a/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/.openspec.yaml b/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/.openspec.yaml new file mode 100644 index 0000000..1b75776 --- /dev/null +++ b/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-25 diff --git a/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/notes.md b/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/notes.md new file mode 100644 index 0000000..1dfae57 --- /dev/null +++ b/openspec/changes/agent-codex-clarify-finished-active-agents-cleanup-s-2026-04-25-16-06/notes.md @@ -0,0 +1,7 @@ +# Notes + +- Rename the Active Agents `finished` UI state to `Needs cleanup` for idle dirty worktrees. +- Keep the underlying `activityKind: "finished"` contract unchanged so existing state derivation remains stable. +- Split needs-cleanup sessions out of `Idle / thinking` so the sidebar explains why the worktree is still visible. +- Sync the shipped VS Code extension template with the canonical extension source. +- Bump the Active Agents extension manifest from `0.0.19` to `0.0.20`. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 463dd80..d96ae0b 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -136,7 +136,7 @@ const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [ const SESSION_ACTIVITY_GROUPS = [ { kind: 'blocked', label: 'BLOCKED' }, { kind: 'working', label: 'WORKING NOW' }, - { kind: 'finished', label: 'FINISHED' }, + { kind: 'finished', label: 'NEEDS CLEANUP' }, { kind: 'idle', label: 'THINKING' }, { kind: 'stalled', label: 'STALLED' }, { kind: 'dead', label: 'DEAD' }, @@ -571,7 +571,7 @@ function buildActiveAgentsStatusSummary(summary) { if (workingCount > 0 || finishedCount > 0 || idleCount > 0) { const parts = [`${workingCount} working`]; if (finishedCount > 0) { - parts.push(`${finishedCount} finished`); + parts.push(`${finishedCount} needs cleanup`); } parts.push(`${idleCount} idle`); return `$(git-branch) ${parts.join(' · ')}`; @@ -594,7 +594,7 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) { return [ formatCountLabel(activeCount, 'active agent'), formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'), - formatCountLabel(summary?.finishedCount || 0, 'finished session'), + formatCountLabel(summary?.finishedCount || 0, 'needs cleanup session'), formatCountLabel(summary?.idleCount || 0, 'idle session'), formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), @@ -681,7 +681,7 @@ function sessionFreshnessLabel(session, now = Date.now()) { return 'Needs attention'; } if (session.activityKind === 'finished') { - return 'Finished'; + return 'Needs cleanup'; } if (session.activityKind === 'stalled') { return 'Possibly stale'; @@ -711,7 +711,7 @@ function sessionStatusLabel(session) { case 'working': return 'Working'; case 'finished': - return 'Finished'; + return 'Needs cleanup'; case 'idle': return 'Idle'; case 'stalled': @@ -918,7 +918,7 @@ function buildWorktreeBranchDescription(sessions) { function buildOverviewDescription(summary) { return [ formatCountLabel(summary?.workingCount || 0, 'working agent'), - formatCountLabel(summary?.finishedCount || 0, 'finished agent'), + formatCountLabel(summary?.finishedCount || 0, 'needs cleanup agent'), formatCountLabel(summary?.idleCount || 0, 'idle agent'), summary?.colonyTaskCount ? formatCountLabel(summary.colonyTaskCount, 'colony task') @@ -2925,7 +2925,9 @@ function buildWorkingNowNodes(sessions) { function buildIdleThinkingNodes(sessions) { const sessionEntries = sortSessionsForIdleThinking( sessions.filter((session) => !( - session.activityKind === 'working' || session.activityKind === 'blocked' + session.activityKind === 'working' + || session.activityKind === 'blocked' + || session.activityKind === 'finished' )), ).map((session) => ({ projectRelativePath: resolveSessionProjectRelativePath(session), @@ -2935,6 +2937,17 @@ function buildIdleThinkingNodes(sessions) { return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); } +function buildNeedsCleanupNodes(sessions) { + const sessionEntries = sessions + .filter((session) => session.activityKind === 'finished') + .map((session) => ({ + projectRelativePath: resolveSessionProjectRelativePath(session), + sessions: [session], + item: new SessionItem(session, buildSessionDetailItems(session)), + })); + return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); +} + function buildUnassignedChangeNodes(changes) { return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, { label: compactRelativePath(change.relativePath), @@ -3205,6 +3218,15 @@ class ActiveAgentsProvider { })); } + const needsCleanupItems = buildNeedsCleanupNodes(element.sessions); + if (needsCleanupItems.length > 0) { + sectionItems.push(new SectionItem('Needs cleanup', needsCleanupItems, { + description: String(needsCleanupItems.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'pass-filled', + })); + } + const idleThinkingItems = buildIdleThinkingNodes(element.sessions); if (idleThinkingItems.length > 0) { sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, { diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index dcf89ba..8c009a2 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": "Recodee", - "version": "0.0.19", + "version": "0.0.20", "license": "MIT", "icon": "icon.png", "engines": { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index ce990fd..de76793 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -1738,7 +1738,7 @@ test('active-agents extension groups live sessions under a repo node', async () const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); assert.equal(repoItem.label, path.basename(tempRoot)); - assert.equal(repoItem.description, '0 working agents · 0 finished agents · 1 idle agent · 0 unassigned changes · 0 locked files · 0 conflicts'); + assert.equal(repoItem.description, '0 working agents · 0 needs cleanup agents · 1 idle agent · 0 unassigned changes · 0 locked files · 0 conflicts'); assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ 'Overview', @@ -1783,6 +1783,70 @@ test('active-agents extension groups live sessions under a repo node', async () } }); +test('active-agents extension labels idle dirty finished worktrees as needing cleanup', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-cleanup-label-')); + initGitRepo(tempRoot); + fs.writeFileSync(path.join(tempRoot, 'tracked.txt'), 'base\n', 'utf8'); + runGit(tempRoot, ['add', 'tracked.txt']); + runGit(tempRoot, ['commit', '-m', 'baseline']); + + const worktreePath = path.join(tempRoot, '.omx', 'agent-worktrees', 'finished-task'); + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + runGit(tempRoot, [ + 'worktree', + 'add', + '-b', + 'agent/codex/finished-task', + worktreePath, + 'HEAD', + ]); + const changedPath = path.join(worktreePath, 'tracked.txt'); + fs.writeFileSync(changedPath, 'base\nleftover cleanup\n', 'utf8'); + setPathMtime(changedPath, Date.now() - (20 * 60 * 1000)); + + const sessionPath = writeSessionRecord(tempRoot, sessionSchema.buildSessionRecord({ + repoRoot: tempRoot, + branch: 'agent/codex/finished-task', + taskName: 'finished-task', + agentName: 'codex', + worktreePath, + pid: process.pid, + cliName: 'codex', + })); + + const { registrations, vscode } = createMockVscode(tempRoot); + vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }]; + 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, '0 working agents · 1 needs cleanup agent · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); + assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ + 'Overview', + 'Needs cleanup', + 'Advanced details', + ]); + + const cleanupSection = await getSectionByLabel(provider, repoItem, 'Needs cleanup'); + const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, cleanupSection); + assert.equal(worktreeItem, null); + assert.equal(sessionItem.label, 'finished-task'); + assert.match(sessionItem.description, /^Needs cleanup: codex · via OpenAI · 1 changed file/); + assert.equal(sessionItem.iconPath.id, 'pass-filled'); + assert.deepEqual(registrations.treeViews[0].badge, { + value: 1, + tooltip: repoItem.description, + }); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + test('active-agents extension discovers nested managed-worktree subprojects under workspace roots', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-subprojects-')); const nestedRepoRoot = path.join(tempRoot, 'gitguardex'); @@ -1827,7 +1891,7 @@ test('active-agents extension discovers nested managed-worktree subprojects unde const [repoItem] = await provider.getChildren(); assert.equal(repoItem.label, `${path.basename(tempRoot)}/gitguardex`); assert.equal(repoItem.repoRoot, nestedRepoRoot); - assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); + assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); @@ -2122,7 +2186,7 @@ test('active-agents extension shows grouped repo changes beside active agents', const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 1 unassigned change · 0 locked files · 0 conflicts'); + assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 1 unassigned change · 0 locked files · 0 conflicts'); assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ 'Overview', 'Working now', @@ -2258,7 +2322,7 @@ test('active-agents extension surfaces live managed worktrees from AGENT.lock fa const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); assert.equal(repoItem.label, `${path.basename(tempRoot)}/gitguardex`); - assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); + assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ 'Overview', @@ -2476,7 +2540,7 @@ test('active-agents extension surfaces plain managed worktrees from workspace fa const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); + assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); @@ -2531,7 +2595,7 @@ test('active-agents extension resolves owning repo sessions when the window is o const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); assert.equal(repoItem.label, path.basename(tempRoot)); - assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); + assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); const { worktreeItem, sessionItem } = await getOnlyWorktreeAndSession(provider, workingSection); @@ -2610,7 +2674,7 @@ test('active-agents extension decorates sessions and repo changes from the lock const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '1 working agent · 0 finished agents · 0 idle agents · 1 unassigned change · 3 locked files · 2 conflicts'); + assert.equal(repoItem.description, '1 working agent · 0 needs cleanup agents · 0 idle agents · 1 unassigned change · 3 locked files · 2 conflicts'); const workingSection = await getSectionByLabel(provider, repoItem, 'Working now'); const unassignedSection = await getSectionByLabel(provider, repoItem, 'Unassigned changes'); const advancedSection = await getSectionByLabel(provider, repoItem, 'Advanced details'); @@ -2861,7 +2925,7 @@ test('active-agents extension groups blocked, working, idle, stalled, and dead s const provider = registrations.providers[0].provider; const [repoItem] = await provider.getChildren(); - assert.equal(repoItem.description, '2 working agents · 0 finished agents · 2 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); + assert.equal(repoItem.description, '2 working agents · 0 needs cleanup agents · 2 idle agents · 0 unassigned changes · 0 locked files · 0 conflicts'); assert.deepEqual((await provider.getChildren(repoItem)).map((item) => item.label), [ 'Overview', diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 463dd80..d96ae0b 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -136,7 +136,7 @@ const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [ const SESSION_ACTIVITY_GROUPS = [ { kind: 'blocked', label: 'BLOCKED' }, { kind: 'working', label: 'WORKING NOW' }, - { kind: 'finished', label: 'FINISHED' }, + { kind: 'finished', label: 'NEEDS CLEANUP' }, { kind: 'idle', label: 'THINKING' }, { kind: 'stalled', label: 'STALLED' }, { kind: 'dead', label: 'DEAD' }, @@ -571,7 +571,7 @@ function buildActiveAgentsStatusSummary(summary) { if (workingCount > 0 || finishedCount > 0 || idleCount > 0) { const parts = [`${workingCount} working`]; if (finishedCount > 0) { - parts.push(`${finishedCount} finished`); + parts.push(`${finishedCount} needs cleanup`); } parts.push(`${idleCount} idle`); return `$(git-branch) ${parts.join(' · ')}`; @@ -594,7 +594,7 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) { return [ formatCountLabel(activeCount, 'active agent'), formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'), - formatCountLabel(summary?.finishedCount || 0, 'finished session'), + formatCountLabel(summary?.finishedCount || 0, 'needs cleanup session'), formatCountLabel(summary?.idleCount || 0, 'idle session'), formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'), formatCountLabel(summary?.lockedFileCount || 0, 'locked file'), @@ -681,7 +681,7 @@ function sessionFreshnessLabel(session, now = Date.now()) { return 'Needs attention'; } if (session.activityKind === 'finished') { - return 'Finished'; + return 'Needs cleanup'; } if (session.activityKind === 'stalled') { return 'Possibly stale'; @@ -711,7 +711,7 @@ function sessionStatusLabel(session) { case 'working': return 'Working'; case 'finished': - return 'Finished'; + return 'Needs cleanup'; case 'idle': return 'Idle'; case 'stalled': @@ -918,7 +918,7 @@ function buildWorktreeBranchDescription(sessions) { function buildOverviewDescription(summary) { return [ formatCountLabel(summary?.workingCount || 0, 'working agent'), - formatCountLabel(summary?.finishedCount || 0, 'finished agent'), + formatCountLabel(summary?.finishedCount || 0, 'needs cleanup agent'), formatCountLabel(summary?.idleCount || 0, 'idle agent'), summary?.colonyTaskCount ? formatCountLabel(summary.colonyTaskCount, 'colony task') @@ -2925,7 +2925,9 @@ function buildWorkingNowNodes(sessions) { function buildIdleThinkingNodes(sessions) { const sessionEntries = sortSessionsForIdleThinking( sessions.filter((session) => !( - session.activityKind === 'working' || session.activityKind === 'blocked' + session.activityKind === 'working' + || session.activityKind === 'blocked' + || session.activityKind === 'finished' )), ).map((session) => ({ projectRelativePath: resolveSessionProjectRelativePath(session), @@ -2935,6 +2937,17 @@ function buildIdleThinkingNodes(sessions) { return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); } +function buildNeedsCleanupNodes(sessions) { + const sessionEntries = sessions + .filter((session) => session.activityKind === 'finished') + .map((session) => ({ + projectRelativePath: resolveSessionProjectRelativePath(session), + sessions: [session], + item: new SessionItem(session, buildSessionDetailItems(session)), + })); + return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' }); +} + function buildUnassignedChangeNodes(changes) { return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, { label: compactRelativePath(change.relativePath), @@ -3205,6 +3218,15 @@ class ActiveAgentsProvider { })); } + const needsCleanupItems = buildNeedsCleanupNodes(element.sessions); + if (needsCleanupItems.length > 0) { + sectionItems.push(new SectionItem('Needs cleanup', needsCleanupItems, { + description: String(needsCleanupItems.length), + collapsedState: vscode.TreeItemCollapsibleState.Collapsed, + iconId: 'pass-filled', + })); + } + const idleThinkingItems = buildIdleThinkingNodes(element.sessions); if (idleThinkingItems.length > 0) { sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, { diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index dcf89ba..8c009a2 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": "Recodee", - "version": "0.0.19", + "version": "0.0.20", "license": "MIT", "icon": "icon.png", "engines": {