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:
LZH-YS1998
2026-07-21 12:54:46 +08:00
parent b73a5a14c3
commit 0c6925402c
8 changed files with 440 additions and 60 deletions
@@ -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