fix: agent status indicators persist after stop — sync btmsg + optimistic UI

Green borders in GroupAgentsPanel and health dots stayed active after
stopping agents because btmsg DB status was never set to 'stopped'.

- Add terminal state guard in agent store (done/error cannot revert to active)
- Optimistic updateAgentStatus('done') on all stop paths (AgentPane, StatusBar Stop All, AgentSession onAgentStop)
- Sync btmsg agent status to 'stopped' in agent-dispatcher on every terminal transition (agent_stopped, agent_error, cost done/error, sidecar crash)
- Sync btmsg optimistically in UI stop handlers for immediate card border update
- Add Stop All button to StatusBar with wake scheduler kill
This commit is contained in:
DexterFromLab 2026-03-15 16:59:20 +01:00
parent 3672e92b7e
commit a3595f0277
5 changed files with 100 additions and 4 deletions

View file

@ -37,10 +37,18 @@ import {
clearSubagentRoutes, clearSubagentRoutes,
} from './utils/subagent-router'; } from './utils/subagent-router';
import { indexMessage } from './adapters/search-bridge'; import { indexMessage } from './adapters/search-bridge';
import { recordHeartbeat } from './adapters/btmsg-bridge'; import { recordHeartbeat, setAgentStatus as setBtmsgAgentStatus } from './adapters/btmsg-bridge';
import { logAuditEvent } from './adapters/audit-bridge'; import { logAuditEvent } from './adapters/audit-bridge';
import type { AgentId } from './types/ids'; import type { AgentId } from './types/ids';
/** Sync btmsg agent status to 'stopped' when a session reaches terminal state */
function syncBtmsgStopped(sessionId: SessionIdType): void {
const projectId = getSessionProjectId(sessionId);
if (projectId) {
setBtmsgAgentStatus(projectId as unknown as AgentId, 'stopped').catch(() => {});
}
}
// Re-export public API consumed by other modules // Re-export public API consumed by other modules
export { registerSessionProject, waitForPendingPersistence } from './utils/session-persistence'; export { registerSessionProject, waitForPendingPersistence } from './utils/session-persistence';
@ -99,6 +107,7 @@ export async function startAgentDispatcher(): Promise<void> {
case 'agent_stopped': case 'agent_stopped':
updateAgentStatus(sessionId, 'done'); updateAgentStatus(sessionId, 'done');
syncBtmsgStopped(sessionId);
tel.info('agent_stopped', { sessionId }); tel.info('agent_stopped', { sessionId });
notify('success', `Agent ${sessionId.slice(0, 8)} completed`); notify('success', `Agent ${sessionId.slice(0, 8)} completed`);
addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined); addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined);
@ -111,6 +120,7 @@ export async function startAgentDispatcher(): Promise<void> {
const errorMsg = msg.message ?? 'Unknown'; const errorMsg = msg.message ?? 'Unknown';
const classified = classifyError(errorMsg); const classified = classifyError(errorMsg);
updateAgentStatus(sessionId, 'error', errorMsg); updateAgentStatus(sessionId, 'error', errorMsg);
syncBtmsgStopped(sessionId);
tel.error('agent_error', { sessionId, error: errorMsg, errorType: classified.type }); tel.error('agent_error', { sessionId, error: errorMsg, errorType: classified.type });
// Show type-specific toast // Show type-specific toast
@ -148,10 +158,11 @@ export async function startAgentDispatcher(): Promise<void> {
if (restarting) return; if (restarting) return;
restarting = true; restarting = true;
// Mark all running sessions as errored // Mark all running sessions as errored + sync btmsg
for (const session of getAgentSessions()) { for (const session of getAgentSessions()) {
if (session.status === 'running' || session.status === 'starting') { if (session.status === 'running' || session.status === 'starting') {
updateAgentStatus(session.id, 'error', 'Sidecar crashed'); updateAgentStatus(session.id, 'error', 'Sidecar crashed');
syncBtmsgStopped(session.id);
} }
} }
@ -278,6 +289,7 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record<string, unknow
const costErrorMsg = cost.errors?.join('; ') ?? 'Unknown error'; const costErrorMsg = cost.errors?.join('; ') ?? 'Unknown error';
const costClassified = classifyError(costErrorMsg); const costClassified = classifyError(costErrorMsg);
updateAgentStatus(sessionId, 'error', costErrorMsg); updateAgentStatus(sessionId, 'error', costErrorMsg);
syncBtmsgStopped(sessionId);
if (costClassified.type === 'rate_limit') { if (costClassified.type === 'rate_limit') {
notify('warning', `Rate limited. ${costClassified.retryDelaySec > 0 ? `Retrying in ~${costClassified.retryDelaySec}s...` : ''}`); notify('warning', `Rate limited. ${costClassified.retryDelaySec > 0 ? `Retrying in ~${costClassified.retryDelaySec}s...` : ''}`);
@ -290,6 +302,7 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record<string, unknow
} }
} else { } else {
updateAgentStatus(sessionId, 'done'); updateAgentStatus(sessionId, 'done');
syncBtmsgStopped(sessionId);
notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`); notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`);
} }
// Health: record token snapshot + tool done // Health: record token snapshot + tool done

View file

@ -5,11 +5,15 @@
import { import {
getAgentSession, getAgentSession,
createAgentSession, createAgentSession,
updateAgentStatus,
getChildSessions, getChildSessions,
getTotalCost, getTotalCost,
} from '../../stores/agents.svelte'; } from '../../stores/agents.svelte';
import { focusPane } from '../../stores/layout.svelte'; import { focusPane } from '../../stores/layout.svelte';
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher'; import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
import { getSessionProjectId } from '../../utils/session-persistence';
import { setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
import type { AgentId } from '../../types/ids';
import { listProfiles, listSkills, readSkill, type ClaudeProfile, type ClaudeSkill } from '../../adapters/claude-bridge'; import { listProfiles, listSkills, readSkill, type ClaudeProfile, type ClaudeSkill } from '../../adapters/claude-bridge';
import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte'; import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
import { estimateTokens } from '../../utils/anchor-serializer'; import { estimateTokens } from '../../utils/anchor-serializer';
@ -261,6 +265,9 @@
} }
function handleStop() { function handleStop() {
updateAgentStatus(sessionId, 'done');
const projId = getSessionProjectId(sessionId);
if (projId) setBtmsgAgentStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
stopAgent(sessionId).catch(() => {}); stopAgent(sessionId).catch(() => {});
} }

View file

@ -1,8 +1,13 @@
<script lang="ts"> <script lang="ts">
import { getAgentSessions } from '../../stores/agents.svelte'; import { getAgentSessions, updateAgentStatus } from '../../stores/agents.svelte';
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte'; import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte';
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte'; import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
import { getTotalConflictCount } from '../../stores/conflicts.svelte'; import { getTotalConflictCount } from '../../stores/conflicts.svelte';
import { clearWakeScheduler } from '../../stores/wake-scheduler.svelte';
import { stopAgent } from '../../adapters/agent-bridge';
import { setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
import { getSessionProjectId } from '../../utils/session-persistence';
import type { AgentId } from '../../types/ids';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { checkForUpdates, installUpdate, type UpdateInfo } from '../../utils/updater'; import { checkForUpdates, installUpdate, type UpdateInfo } from '../../utils/updater';
import { message as dialogMessage, confirm } from '@tauri-apps/plugin-dialog'; import { message as dialogMessage, confirm } from '@tauri-apps/plugin-dialog';
@ -23,6 +28,28 @@
let totalConflicts = $derived(getTotalConflictCount()); let totalConflicts = $derived(getTotalConflictCount());
let showAttention = $state(false); let showAttention = $state(false);
// Stop All state
let stopping = $state(false);
let hasActive = $derived(health.running > 0 || health.idle > 0 || health.stalled > 0);
async function handleStopAll() {
if (stopping) return;
stopping = true;
try {
clearWakeScheduler();
const sessions = getAgentSessions();
const active = sessions.filter(s => s.status === 'running' || s.status === 'starting' || s.status === 'idle');
for (const s of active) {
updateAgentStatus(s.id, 'done');
const projId = getSessionProjectId(s.id);
if (projId) setBtmsgAgentStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
}
await Promise.all(active.map(s => stopAgent(s.id).catch(() => {})));
} finally {
stopping = false;
}
}
// Auto-update state // Auto-update state
let updateInfo = $state<UpdateInfo | null>(null); let updateInfo = $state<UpdateInfo | null>(null);
let installing = $state(false); let installing = $state(false);
@ -124,6 +151,21 @@
</div> </div>
<div class="right"> <div class="right">
{#if hasActive}
<button
class="item stop-all-btn"
onclick={handleStopAll}
disabled={stopping}
title="Stop all agents and wake scheduler"
>
{#if stopping}
Stopping...
{:else}
■ Stop All
{/if}
</button>
<span class="sep"></span>
{/if}
{#if health.totalBurnRatePerHour > 0} {#if health.totalBurnRatePerHour > 0}
<span class="item burn-rate" title="Total burn rate across active sessions"> <span class="item burn-rate" title="Total burn rate across active sessions">
{formatRate(health.totalBurnRatePerHour)} {formatRate(health.totalBurnRatePerHour)}
@ -284,6 +326,32 @@
background: var(--ctp-red); background: var(--ctp-red);
} }
/* Stop All */
.stop-all-btn {
background: color-mix(in srgb, var(--ctp-red) 15%, transparent);
border: 1px solid var(--ctp-red);
border-radius: 0.25rem;
color: var(--ctp-red);
font: inherit;
font-size: 0.625rem;
font-weight: 600;
padding: 0 0.375rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
line-height: 1.25rem;
}
.stop-all-btn:hover {
background: color-mix(in srgb, var(--ctp-red) 25%, transparent);
}
.stop-all-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* Burn rate */ /* Burn rate */
.burn-rate { .burn-rate {
color: var(--ctp-mauve); color: var(--ctp-mauve);

View file

@ -27,7 +27,7 @@
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte'; import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
import { loadAnchorsForProject } from '../../stores/anchors.svelte'; import { loadAnchorsForProject } from '../../stores/anchors.svelte';
import { getSecret } from '../../adapters/secrets-bridge'; import { getSecret } from '../../adapters/secrets-bridge';
import { getUnseenMessages, markMessagesSeen } from '../../adapters/btmsg-bridge'; import { getUnseenMessages, markMessagesSeen, setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte'; import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte';
import { SessionId, ProjectId } from '../../types/ids'; import { SessionId, ProjectId } from '../../types/ids';
import AgentPane from '../Agent/AgentPane.svelte'; import AgentPane from '../Agent/AgentPane.svelte';
@ -158,6 +158,8 @@ bttask comment <task-id> "update" # Add a comment
// Listen for stop-button events from GroupAgentsPanel // Listen for stop-button events from GroupAgentsPanel
const unsubAgentStop = onAgentStop((projectId) => { const unsubAgentStop = onAgentStop((projectId) => {
if (projectId !== project.id) return; if (projectId !== project.id) return;
updateAgentStatus(sessionId, 'done');
setBtmsgAgentStatus(project.id as unknown as AgentId, 'stopped').catch(() => {});
stopAgent(sessionId).catch(() => {}); stopAgent(sessionId).catch(() => {});
}); });

View file

@ -62,6 +62,12 @@ export function createAgentSession(id: string, prompt: string, parent?: { sessio
export function updateAgentStatus(id: string, status: AgentStatus, error?: string): void { export function updateAgentStatus(id: string, status: AgentStatus, error?: string): void {
const session = sessions.find(s => s.id === id); const session = sessions.find(s => s.id === id);
if (!session) return; if (!session) return;
// Never transition from terminal states back to active states
// (prevents late sidecar events from overriding optimistic stop)
if ((session.status === 'done' || session.status === 'error') &&
(status === 'running' || status === 'starting' || status === 'idle')) {
return;
}
session.status = status; session.status = status;
if (error) session.error = error; if (error) session.error = error;
} }