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:
parent
3672e92b7e
commit
a3595f0277
5 changed files with 100 additions and 4 deletions
|
|
@ -37,10 +37,18 @@ import {
|
|||
clearSubagentRoutes,
|
||||
} from './utils/subagent-router';
|
||||
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 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
|
||||
export { registerSessionProject, waitForPendingPersistence } from './utils/session-persistence';
|
||||
|
||||
|
|
@ -99,6 +107,7 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
|
||||
case 'agent_stopped':
|
||||
updateAgentStatus(sessionId, 'done');
|
||||
syncBtmsgStopped(sessionId);
|
||||
tel.info('agent_stopped', { sessionId });
|
||||
notify('success', `Agent ${sessionId.slice(0, 8)} completed`);
|
||||
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 classified = classifyError(errorMsg);
|
||||
updateAgentStatus(sessionId, 'error', errorMsg);
|
||||
syncBtmsgStopped(sessionId);
|
||||
tel.error('agent_error', { sessionId, error: errorMsg, errorType: classified.type });
|
||||
|
||||
// Show type-specific toast
|
||||
|
|
@ -148,10 +158,11 @@ export async function startAgentDispatcher(): Promise<void> {
|
|||
if (restarting) return;
|
||||
restarting = true;
|
||||
|
||||
// Mark all running sessions as errored
|
||||
// Mark all running sessions as errored + sync btmsg
|
||||
for (const session of getAgentSessions()) {
|
||||
if (session.status === 'running' || session.status === 'starting') {
|
||||
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 costClassified = classifyError(costErrorMsg);
|
||||
updateAgentStatus(sessionId, 'error', costErrorMsg);
|
||||
syncBtmsgStopped(sessionId);
|
||||
|
||||
if (costClassified.type === 'rate_limit') {
|
||||
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 {
|
||||
updateAgentStatus(sessionId, 'done');
|
||||
syncBtmsgStopped(sessionId);
|
||||
notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`);
|
||||
}
|
||||
// Health: record token snapshot + tool done
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@
|
|||
import {
|
||||
getAgentSession,
|
||||
createAgentSession,
|
||||
updateAgentStatus,
|
||||
getChildSessions,
|
||||
getTotalCost,
|
||||
} from '../../stores/agents.svelte';
|
||||
import { focusPane } from '../../stores/layout.svelte';
|
||||
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 { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
|
||||
import { estimateTokens } from '../../utils/anchor-serializer';
|
||||
|
|
@ -261,6 +265,9 @@
|
|||
}
|
||||
|
||||
function handleStop() {
|
||||
updateAgentStatus(sessionId, 'done');
|
||||
const projId = getSessionProjectId(sessionId);
|
||||
if (projId) setBtmsgAgentStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
|
||||
stopAgent(sessionId).catch(() => {});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<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 { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.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 { checkForUpdates, installUpdate, type UpdateInfo } from '../../utils/updater';
|
||||
import { message as dialogMessage, confirm } from '@tauri-apps/plugin-dialog';
|
||||
|
|
@ -23,6 +28,28 @@
|
|||
let totalConflicts = $derived(getTotalConflictCount());
|
||||
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
|
||||
let updateInfo = $state<UpdateInfo | null>(null);
|
||||
let installing = $state(false);
|
||||
|
|
@ -124,6 +151,21 @@
|
|||
</div>
|
||||
|
||||
<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}
|
||||
<span class="item burn-rate" title="Total burn rate across active sessions">
|
||||
{formatRate(health.totalBurnRatePerHour)}
|
||||
|
|
@ -284,6 +326,32 @@
|
|||
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 {
|
||||
color: var(--ctp-mauve);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
|
||||
import { loadAnchorsForProject } from '../../stores/anchors.svelte';
|
||||
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 { SessionId, ProjectId } from '../../types/ids';
|
||||
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
|
||||
const unsubAgentStop = onAgentStop((projectId) => {
|
||||
if (projectId !== project.id) return;
|
||||
updateAgentStatus(sessionId, 'done');
|
||||
setBtmsgAgentStatus(project.id as unknown as AgentId, 'stopped').catch(() => {});
|
||||
stopAgent(sessionId).catch(() => {});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ export function createAgentSession(id: string, prompt: string, parent?: { sessio
|
|||
export function updateAgentStatus(id: string, status: AgentStatus, error?: string): void {
|
||||
const session = sessions.find(s => s.id === id);
|
||||
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;
|
||||
if (error) session.error = error;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue