mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 11:06:24 +00:00
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:
@@ -144,11 +144,12 @@ export function AgentsSection() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{a.status === "working" && a.last_output?.trim() && (
|
||||
{(a.status === "working" || a.status === "crashed") && a.last_output?.trim() && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ paddingTop: 0 }}>
|
||||
<div className="faint" style={{ fontSize: "var(--text-xs)", marginBottom: 4 }}>
|
||||
live output{a.output_at ? ` · ${timeAgo(a.output_at)}` : ""}
|
||||
{a.status === "crashed" ? "last output before crash" : "live output"}
|
||||
{a.output_at ? ` · ${timeAgo(a.output_at)}` : ""}
|
||||
</div>
|
||||
<pre
|
||||
className="mono"
|
||||
|
||||
@@ -7,13 +7,14 @@ import { useMemo, useState } from "react";
|
||||
import { useDashboard } from "@/components/store";
|
||||
import { Badge, Button, Callout, Stat, StatusBadge, Tabs, Textarea } from "@/components/ui";
|
||||
import { fmtFull, shortSha, statusTone, timeAgo } from "@/lib/format";
|
||||
import type { Agent } from "@/lib/api";
|
||||
import type { Agent, AgentEvent } from "@/lib/api";
|
||||
|
||||
const FILTERS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "needs", label: "Needs Input" },
|
||||
{ value: "working", label: "Working" },
|
||||
{ value: "done", label: "Done" },
|
||||
{ value: "crashed", label: "Crashed" },
|
||||
];
|
||||
|
||||
function matches(filter: string, status: string): boolean {
|
||||
@@ -21,6 +22,7 @@ function matches(filter: string, status: string): boolean {
|
||||
if (filter === "needs") return status === "paused_for_input";
|
||||
if (filter === "working") return status === "working" || status === "running";
|
||||
if (filter === "done") return status === "done" || status === "completed";
|
||||
if (filter === "crashed") return status === "crashed" || status === "blocked";
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -239,6 +241,40 @@ function RunDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Headless run event stream (empty for legacy tmux agents) */}
|
||||
{agent?.session_id && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div className="eyebrow">
|
||||
Run events
|
||||
{agent.worker_id ? (
|
||||
<span className="faint mono" style={{ fontSize: "var(--text-xs)", marginLeft: 8 }}>
|
||||
on {agent.worker_id}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{s.events.length === 0 ? (
|
||||
<div className="empty">No events yet.</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
maxHeight: 420,
|
||||
overflow: "auto",
|
||||
padding: "10px 12px",
|
||||
background: "var(--surface-2, rgba(0,0,0,0.25))",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{s.events.map((e) => (
|
||||
<EventLine key={e.id} e={e} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div className="eyebrow">Log · newest first</div>
|
||||
@@ -304,3 +340,99 @@ function RunDetail() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* One stream-json event, rendered by type: assistant text as prose, tool calls as chips,
|
||||
* the result as a cost/turns footer, worker notices as callouts, raw lines verbatim. */
|
||||
function EventLine({ e }: { e: AgentEvent }) {
|
||||
const p = (e.payload ?? {}) as Record<string, any>;
|
||||
const xs = { fontSize: "var(--text-xs)" } as const;
|
||||
|
||||
if (e.type === "system") {
|
||||
return (
|
||||
<div className="faint mono" style={xs}>
|
||||
▸ session {p.subtype ?? "event"}
|
||||
{p.session_id ? ` · ${String(p.session_id).slice(0, 8)}` : ""}
|
||||
{Array.isArray(p.tools) ? ` · ${p.tools.length} tools` : ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (e.type === "assistant") {
|
||||
const content = p.message?.content;
|
||||
const blocks: any[] = Array.isArray(content) ? content : [];
|
||||
const text = blocks
|
||||
.filter((b) => b?.type === "text" && b.text)
|
||||
.map((b) => b.text)
|
||||
.join("\n");
|
||||
const tools = blocks.filter((b) => b?.type === "tool_use");
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{text && (
|
||||
<div style={{ fontSize: "var(--text-sm)", whiteSpace: "pre-wrap" }}>{text}</div>
|
||||
)}
|
||||
{tools.length > 0 && (
|
||||
<div className="hstack" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||
{tools.map((t, i) => (
|
||||
<Badge key={i} tone="info">
|
||||
{t.name}
|
||||
{t.input ? `: ${oneLine(t.input)}` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (e.type === "result") {
|
||||
const err = Boolean(p.is_error);
|
||||
return (
|
||||
<div className="hstack" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||
<Badge tone={err ? "danger" : "success"}>{err ? "run errored" : "run finished"}</Badge>
|
||||
<span className="faint mono" style={xs}>
|
||||
{p.num_turns != null ? `${p.num_turns} turns` : ""}
|
||||
{p.total_cost_usd != null ? ` · $${Number(p.total_cost_usd).toFixed(4)}` : ""}
|
||||
</span>
|
||||
{typeof p.result === "string" && p.result && (
|
||||
<span className="muted" style={{ ...xs, whiteSpace: "pre-wrap", width: "100%" }}>
|
||||
{p.result}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (e.type === "worker") {
|
||||
return (
|
||||
<Callout tone="danger">
|
||||
{p.notice ?? "runner notice"}
|
||||
{p.stderr_tail ? (
|
||||
<pre className="mono" style={{ ...xs, margin: "6px 0 0", whiteSpace: "pre-wrap" }}>
|
||||
{p.stderr_tail}
|
||||
</pre>
|
||||
) : null}
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
if (e.type === "raw") {
|
||||
return (
|
||||
<div className="faint mono" style={{ ...xs, whiteSpace: "pre-wrap" }}>
|
||||
{typeof p.line === "string" ? p.line.trimEnd() : JSON.stringify(p)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// user (tool results) and anything future: a quiet one-liner, nothing lost, no noise.
|
||||
return (
|
||||
<div className="faint mono" style={xs}>
|
||||
▸ {e.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Compact single-line preview of a tool_use input object. */
|
||||
function oneLine(input: unknown): string {
|
||||
const s =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: (input as Record<string, unknown>)?.command
|
||||
? String((input as Record<string, unknown>).command)
|
||||
: JSON.stringify(input);
|
||||
return s.length > 80 ? `${s.slice(0, 77)}…` : s;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
-2
@@ -30,10 +30,28 @@ export interface Agent {
|
||||
working_dir: string;
|
||||
status: string;
|
||||
role?: string | null;
|
||||
/* Latest tmux pane-tail snapshot from the worker, so the UI can show what a running
|
||||
* agent is actually doing (and expose one wedged on an interactive prompt). */
|
||||
/* Latest output snapshot from the worker: the tmux pane tail for legacy agents, the
|
||||
* latest assistant text for headless runs. For a crashed agent this is the evidence
|
||||
* frame — the last thing the process said. */
|
||||
last_output?: string | null;
|
||||
output_at?: string | null;
|
||||
/* Headless runner: claude session UUID (null = legacy tmux agent) + supervising worker. */
|
||||
session_id?: string | null;
|
||||
worker_id?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* One persisted stream-json event of a headless run (GET .../events, cursor-paged by id).
|
||||
* `type` mirrors the stream (system/assistant/user/result) plus `worker` (runner notices)
|
||||
* and `raw` (unparseable line kept verbatim). */
|
||||
export interface AgentEvent {
|
||||
id: number;
|
||||
agent_id: number;
|
||||
run_id: number;
|
||||
session_id?: string | null;
|
||||
seq: number;
|
||||
type: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export function statusTone(status: string | null | undefined): Tone {
|
||||
case "blocked":
|
||||
case "rejected":
|
||||
case "error":
|
||||
case "crashed":
|
||||
return "danger";
|
||||
case "pending":
|
||||
case "queued":
|
||||
|
||||
Reference in New Issue
Block a user