fix(ui): reconcile runtime status so stale chips converge without a refresh (#11)
Live status deltas are one-shot best-effort broadcasts: a delta lost to a
disconnect window, a project-scope drop, or a not-ready store left the UI
stuck on a stale status ("thinking" vs stopped) until a hard refresh, which
rebuilds from the always-correct snapshot. Close the class, not the sites:
- Add a low-frequency runtime_status_sync reconciliation broadcast: every
12s (lazily started, cancelled on shutdown) re-broadcast the persisted
status + in-memory tracker state of every task with a live runtime, plus
one final tick for tasks that just ended. Candidates come purely from
in-memory registries (no table scans); idle system pays nothing.
- Frontend consumes it diff-before-dispatch: a tick where nothing drifted
triggers zero store updates and zero re-renders; clearing mirrors the
mergeLiveRuntimeField semantics already used by collab_sync.
- Fix the EventAdapter tracker state machine: tool_completed returns to
REFLECTING (the turn is still running), and turn_completed/turn_failed
now transition to IDLE and emit an authoritative idle runtime update.
- Guarantee the terminal board_task_status_changed in _run_session_task's
finally: a cancelled run previously skipped it, leaving the board on
"running". The fallback mirrors persisted state read-only.
Verified: 7 new tests in test_runtime_status_sync.py; 236 backend tests
pass with zero new failures; tsc clean; frontend structural tests pass;
dist rebuilt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1449,6 +1449,56 @@ export default function App() {
|
||||
scheduleSessionDetailRefresh(payload.task_id)
|
||||
}
|
||||
},
|
||||
onRuntimeStatusSync: (payload) => {
|
||||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||||
const ss = sessionStoreRef.current
|
||||
const bs = boardStoreRef.current
|
||||
if (!ss) return
|
||||
// Periodic reconciliation against the backend's authoritative status.
|
||||
// Diff before dispatching: a tick where nothing drifted must not
|
||||
// trigger a single store update (and therefore no re-render).
|
||||
for (const entry of payload.sessions ?? []) {
|
||||
const taskId = String(entry.task_id ?? '').trim()
|
||||
if (!taskId) continue
|
||||
const session = ss.sessions.find((s) => s.taskId === taskId)
|
||||
if (!session) continue
|
||||
const status = String(entry.status ?? '').trim()
|
||||
const patch: Partial<import('./types/kanban').Session> = {}
|
||||
if (status && status !== session.status) patch.status = status
|
||||
const rawAgentStatus = typeof entry.agent_status === 'string' ? entry.agent_status.trim() : ''
|
||||
if (rawAgentStatus === 'idle' || rawAgentStatus === 'reflecting' || rawAgentStatus === 'tool_active') {
|
||||
if (rawAgentStatus !== session.agentStatus) patch.agentStatus = rawAgentStatus
|
||||
const tool = typeof entry.current_tool === 'string' && entry.current_tool.trim()
|
||||
? entry.current_tool
|
||||
: undefined
|
||||
if (tool !== session.currentTool) patch.currentTool = tool
|
||||
if (runtimeStatusClearsDisplayTool(rawAgentStatus) && session.displayTool !== undefined) {
|
||||
patch.displayTool = undefined
|
||||
}
|
||||
} else {
|
||||
// No live tracker for this task: only clear stale indicators when
|
||||
// the backend says the task is no longer running (mirrors the
|
||||
// mergeLiveRuntimeField semantics used by collab_sync).
|
||||
const controlActive = session.runtimeControlState === 'running'
|
||||
|| session.runtimeControlState === 'suspending'
|
||||
|| session.runtimeControlState === 'resuming'
|
||||
if (status && status !== 'running' && !controlActive) {
|
||||
if (session.agentStatus !== undefined) patch.agentStatus = undefined
|
||||
if (session.currentTool !== undefined) patch.currentTool = undefined
|
||||
if (session.displayTool !== undefined) patch.displayTool = undefined
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length === 0) continue
|
||||
ss.updateSession(taskId, patch)
|
||||
if (bs && ('agentStatus' in patch || 'currentTool' in patch || 'displayTool' in patch)) {
|
||||
const boardPatch: Partial<KanbanTask> = {}
|
||||
if ('agentStatus' in patch) boardPatch.agentStatus = patch.agentStatus as KanbanTask['agentStatus']
|
||||
if ('currentTool' in patch) boardPatch.currentTool = patch.currentTool
|
||||
if ('displayTool' in patch) boardPatch.displayTool = patch.displayTool
|
||||
bs.updateTask(taskId, boardPatch)
|
||||
}
|
||||
}
|
||||
},
|
||||
onWorkerNotification: (payload) => {
|
||||
if (!payloadMatchesActiveProject(payload as unknown as Record<string, unknown>, false)) return
|
||||
const data = payload as Record<string, unknown>
|
||||
|
||||
@@ -29,6 +29,7 @@ interface SocketHandlers {
|
||||
onCrossOfficeCollab?: (payload: { agent_ids: string[]; task_id: string; action: string }) => void
|
||||
onCollabMessage?: (type: string, payload: Record<string, unknown>) => void
|
||||
onAgentRuntimeUpdate?: (payload: AgentRuntimePayload) => void
|
||||
onRuntimeStatusSync?: (payload: RuntimeStatusSyncPayload) => void
|
||||
onWorkerNotification?: (payload: WorkerNotificationPayload) => void
|
||||
onKanbanViewData?: (payload: KanbanViewDataPayload) => void
|
||||
onSessionCreated?: (payload: { project_id: string; task_id: string; channel_id: string; session_id?: string; parent_session_id?: string; origin_task_id?: string; title: string; status: string; created_at: number; assignee_ids?: string[]; exec_mode?: string; company_profile?: string; org_id?: string; organization_id?: string; preferred_agent?: TaskPreferredAgent; selected_execution_agent?: TaskPreferredAgent }) => void
|
||||
@@ -60,6 +61,16 @@ interface SocketHandlers {
|
||||
onCommsMessage?: (payload: CommsMessagePayload) => void
|
||||
}
|
||||
|
||||
export interface RuntimeStatusSyncPayload {
|
||||
project_id: string
|
||||
sessions: Array<{
|
||||
task_id: string
|
||||
status: string
|
||||
agent_status?: string
|
||||
current_tool?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface CommsMessageItem {
|
||||
message_id: string
|
||||
from: string
|
||||
@@ -752,6 +763,9 @@ export class VisualSocketClient {
|
||||
case 'agent_runtime_update':
|
||||
this.handlers.onAgentRuntimeUpdate?.(parsed.payload)
|
||||
break
|
||||
case 'runtime_status_sync':
|
||||
this.handlers.onRuntimeStatusSync?.(parsed.payload)
|
||||
break
|
||||
case 'worker_notification':
|
||||
this.handlers.onWorkerNotification?.(parsed.payload as WorkerNotificationPayload)
|
||||
break
|
||||
|
||||
@@ -119,6 +119,7 @@ export type SocketEnvelope =
|
||||
| { type: 'comms_state'; payload: Record<string, unknown> }
|
||||
| { type: 'comms_message'; payload: Record<string, unknown> }
|
||||
| { type: 'comms_state_dirty'; payload: { project_id: string; [key: string]: unknown } }
|
||||
| { type: 'runtime_status_sync'; payload: { project_id: string; sessions: Array<{ task_id: string; status: string; agent_status?: string; current_tool?: string | null }> } }
|
||||
|
||||
export type SocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user