feat(control,api,ui): worker liveness + run event stream (phase 3)

- worker: heartbeat every loop pass (workers registry); reaper pass
  every ~15s marks a silent worker's running runs crashed and flips
  agents stuck in 'working' to crashed (paused/blocked keep their
  still-accurate status). Idempotent via finish_run's running-guard;
  any surviving worker can reap; no auto-requeue (half-done runs may
  have pushed). Dead workers' registry rows are dropped once settled.
- api: GET /projects/{p}/agents/{name}/events - the persisted
  stream-json event log, oldest-first, cursor-paged by row id;
  AgentOut exposes session_id/worker_id
- frontend: Run events panel in the run detail (assistant text, tool
  chips, result footer with cost/turns, runner notices, raw lines),
  cursor-appended on the existing 5s poll; 'Crashed' filter + danger
  badge; crashed agents show their frozen last frame ('last output
  before crash'); static export regenerated

Suite 290 -> 296 green; next build clean.
This commit is contained in:
2026-07-21 23:10:40 -04:00
parent 650f376934
commit 6c2e73d4ec
20 changed files with 428 additions and 16 deletions
+21
View File
@@ -17,6 +17,7 @@ import {
AuthError,
createClient,
type Agent,
type AgentEvent,
type ApiError,
type Approval,
type Checkmark,
@@ -83,6 +84,9 @@ interface StoreValue {
log: LogEntry[];
logOffset: number;
pageLog: (dir: 1 | -1) => void;
/* Headless run event stream for the selected run, oldest-first, appended by cursor
* polls (empty for legacy tmux agents). */
events: AgentEvent[];
approvals: Approval[];
hosts: Host[];
@@ -204,6 +208,9 @@ export function DashboardProvider({
const [checkmarkMissing, setCheckmarkMissing] = useState(false);
const [log, setLog] = useState<LogEntry[]>([]);
const [logOffset, setLogOffset] = useState(0);
const [events, setEvents] = useState<AgentEvent[]>([]);
const eventsRef = useRef<AgentEvent[]>([]);
eventsRef.current = events;
const [approvals, setApprovals] = useState<Approval[]>([]);
const [hosts, setHosts] = useState<Host[]>([]);
const [commands, setCommands] = useState<Command[]>([]);
@@ -282,6 +289,17 @@ export function DashboardProvider({
} catch (e) {
swallow(e);
}
try {
// Cursor poll: only events newer than what we already hold come back.
const cur = eventsRef.current;
const after = cur.length ? cur[cur.length - 1].id : 0;
const fresh = await clientRef.current.api<AgentEvent[]>(
`${path}/events?after_id=${after}&limit=500`,
);
if (fresh.length) setEvents((prev) => [...prev, ...fresh]);
} catch (e) {
swallow(e);
}
}, []);
const loadApprovals = useCallback(async (projectId: string) => {
@@ -407,6 +425,8 @@ export function DashboardProvider({
setCheckmark(null);
setCheckmarkMissing(false);
setLog([]);
setEvents([]);
eventsRef.current = [];
void loadRun(projectId, name);
},
[loadRun],
@@ -925,6 +945,7 @@ export function DashboardProvider({
log,
logOffset,
pageLog,
events,
approvals,
hosts,
commands,