feat(world): the agent page can answer what an agent DID, not only what it is doing
deploy / test (push) Successful in 6m13s
deploy / build (push) Successful in 6m47s

The command centre's metric band reads a live feed: tokens in the last minute,
credits in the last hour, active routines, pending approvals. Every one of those
is correctly zero once a mission ends — so an operator opening an agent that ran
`JEPA Research` was shown six zeros, with nothing saying the page had understood
a different question than the one they asked.

The data was never missing. `usage_events` carries a row per turn and
`mission_events` carries every attributed tool call. Verified against production
before any of this was written:

    Tomasz     21,697 tokens   22.00 credits   96 tool calls
    Seong-min  18,125          19.00           49
    Adrian     13,855          14.00           32
    Yara        9,686          11.00           11
    Wei         7,228           8.00           18
    Osei        4,304           5.00            5

The tool counts sum to 211, which is exactly what `mission_events` holds. The
page simply never asked.

`agent.last_run` is a SEPARATE taxonomy event, not a fallback folded into
`telemetry`, and that is the whole design. `agent.task.update` already refuses to
emit for a finished mission so that "idle" stays truthful; quietly substituting
a two-day-old number into a tile the UI promises is live would undo exactly
that. The two travel apart and the card says which it is showing:

  SPEND        last-run credits, unit becomes `cr total`, tagged LAST RUN
  THROUGHPUT   last-run tokens, unit becomes `tokens · last run`, and the
               sparkline is SUPPRESSED — a flat line drawn from one repeated
               number reads as "measured and steady" when nothing was measured
  WORKING ON   idle stays idle, but names the mission, tool calls, tokens,
    NOW        status and how long ago, instead of one line of nothing
  LOOPS/DOORS  left live; zero is the correct answer there

Live always wins. History appears only where the live value is genuinely
nothing, so an agent mid-turn can never see a stale figure.

Two details that would have been silent bugs:

- `stateKey` keys the retained value per AGENT. One shared key would let the
  last agent in the roster overwrite every other agent's summary, and a late
  subscriber would paint one agent's last run onto all of them — plausible
  numbers belonging to someone else.
- `usage_events` carries no mission id, so its rows are attributed by the
  mission's time window. `mission_events` needs no such guess, which is why the
  tool count is the trustworthy half of the row and the token figure is the
  approximate one. Said so in the doc comment rather than implying both are
  equally solid.

Refreshed on the seed and then once a minute, not on the 2s poll: historical by
definition, but not seed-only either, or a mission finishing mid-session leaves
the card reading whatever it read before.

Suite: 108 binaries, 842 Rust tests, 92 frontend tests, tsc clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
Omar Sobh
2026-08-28 20:55:32 -05:00
co-authored by Claude Opus 5
parent 2f1a870949
commit f26de3ba76
5 changed files with 462 additions and 15 deletions
+295
View File
@@ -694,6 +694,96 @@ async fn agent_telemetry(
m m
} }
/// What an agent did on its most recent FINISHED mission.
///
/// The metric band is a live command centre: tokens over the last minute,
/// credits over the last hour, active routines, pending approvals. Every one of
/// those is correctly zero for an agent whose mission ended, so the page an
/// operator opens to ask "what did this agent do" answers with six zeros.
///
/// This is the other half, and it is deliberately a SEPARATE event rather than
/// a fallback folded into `telemetry`. `agent.task.update` already refuses to
/// emit for a finished mission so that "idle" stays truthful, and quietly
/// substituting a two-day-old number into a live tile would undo exactly that.
/// The client decides how to label it; the protocol keeps them apart.
struct AgentLastRun {
mission_id: uuid::Uuid,
title: String,
status: String,
ended_at: Option<time::OffsetDateTime>,
tokens: i64,
credits: f64,
tool_calls: i64,
}
/// Every agent's last finished mission, in ONE query rather than N.
///
/// `usage_events` carries no mission id, so its rows are attributed by time
/// window — the mission's own span, plus a small tail because a turn's usage is
/// recorded as the turn settles rather than before the mission is marked
/// complete. `mission_events` needs no such guess: it carries `mission_id` and
/// `agent_id` directly, which is why the tool count is the trustworthy half of
/// this row and the token figure is the approximate one.
async fn agent_last_run(
pool: &PgPool,
ws: WorkspaceId,
) -> std::collections::HashMap<String, AgentLastRun> {
let mut m = std::collections::HashMap::new();
let rows = sqlx::query(
"WITH latest AS (
SELECT DISTINCT ON (tm.claw_id)
tm.claw_id AS agent_id, m.id AS mission_id, m.title, m.status,
COALESCE(m.completed_at, m.updated_at) AS ended_at,
m.created_at AS started_at
FROM team_members tm
JOIN mission_teams mt ON mt.team_id = tm.team_id
JOIN missions m ON m.id = mt.mission_id
WHERE m.workspace_id = $1
AND m.status IN ('completed', 'failed')
ORDER BY tm.claw_id, COALESCE(m.completed_at, m.updated_at) DESC
)
SELECT l.agent_id, l.mission_id, l.title, l.status, l.ended_at,
COALESCE(u.tokens, 0) AS tokens,
COALESCE(u.credits, 0) AS credits,
COALESCE(t.tool_calls, 0) AS tool_calls
FROM latest l
LEFT JOIN LATERAL (
SELECT SUM(tokens_in + tokens_out)::bigint AS tokens,
SUM(credits)::float8 AS credits
FROM usage_events ue
WHERE ue.agent_id = l.agent_id
AND ue.created_at BETWEEN l.started_at AND l.ended_at + interval '5 minutes'
) u ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::bigint AS tool_calls
FROM mission_events me
WHERE me.mission_id = l.mission_id
AND me.agent_id = l.agent_id
AND me.kind = 'tool.call'
) t ON TRUE",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default();
for r in rows {
let agent_id: uuid::Uuid = r.get("agent_id");
m.insert(
agent_id.to_string(),
AgentLastRun {
mission_id: r.get("mission_id"),
title: r.get("title"),
status: r.get("status"),
ended_at: r.get("ended_at"),
tokens: r.get("tokens"),
credits: r.get("credits"),
tool_calls: r.get("tool_calls"),
},
);
}
m
}
/// Query for `GET /api/world/live`. /// Query for `GET /api/world/live`.
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct LiveQuery { pub struct LiveQuery {
@@ -715,6 +805,11 @@ pub async fn world_live(
let stream = async_stream::stream! { let stream = async_stream::stream! {
let mut first = true; let mut first = true;
// How many polls since the last-run summary was refreshed. Historical
// by definition, so it does not belong on the 2s cadence — but it must
// not be seed-only either, or a mission finishing mid-session leaves
// the card reading whatever it read before.
let mut polls: u32 = 0;
// Remember last status per agent so we only push deltas after the seed. // Remember last status per agent so we only push deltas after the seed.
let mut last: std::collections::HashMap<String, String> = std::collections::HashMap::new(); let mut last: std::collections::HashMap<String, String> = std::collections::HashMap::new();
// Per-run journal cursor so we stream only NEW run_events each poll. // Per-run journal cursor so we stream only NEW run_events each poll.
@@ -1106,6 +1201,28 @@ pub async fn world_live(
})); }));
} }
// The last finished mission, refreshed on the seed and then once a
// minute. Stateful on the client, so a late subscriber paints it
// immediately instead of waiting for the next refresh.
if first || polls % 30 == 0 {
let last_runs = agent_last_run(&pool, ws).await;
for a in &roster {
let id = a.id.to_string();
let Some(lr) = last_runs.get(&id) else { continue };
yield sse("agent.last_run", json!({
"agentId": id,
"missionId": lr.mission_id.to_string(),
"title": lr.title,
"status": lr.status,
"endedAt": lr.ended_at.map(|t| t.unix_timestamp()),
"tokens": lr.tokens,
"credits": lr.credits,
"toolCalls": lr.tool_calls,
}));
}
}
polls = polls.wrapping_add(1);
// Per-agent LIVE column: REASONING STREAM + tool lines. // Per-agent LIVE column: REASONING STREAM + tool lines.
// //
// These two taxonomy types were declared and listened for since the // These two taxonomy types were declared and listened for since the
@@ -1510,3 +1627,181 @@ mod mission_feed_tests {
); );
} }
} }
/// Does the agent command centre have anything to say about a finished mission?
///
/// Its metric cards read a LIVE feed: tokens in the last minute, credits in the
/// last hour, active routines, pending approvals. Every one is correctly zero
/// once a mission ends, so an operator opening the page to ask "what did this
/// agent do" was answered with six zeros and nothing saying the question had
/// been understood differently than they meant it.
///
/// The data was never missing — `usage_events` carries a row per turn and
/// `mission_events` every attributed tool call. Nothing queried them. That is
/// why this is a test: the failure produced no error anywhere, every query ran,
/// and the answer was honestly nothing.
#[cfg(test)]
mod last_run_tests {
use cm_domain::WorkspaceId;
use uuid::Uuid;
/// One finished mission, a crew of one, two turns of usage and four tool
/// calls — of which one belongs to nobody.
///
/// Raw SQL on purpose: this asserts on the SHAPE of the join
/// (team_members → mission_teams → missions), and going through helpers
/// that already assume that shape would be testing itself.
async fn seed(pool: &sqlx::PgPool) -> (WorkspaceId, Uuid) {
let ws = WorkspaceId::new();
let agent = Uuid::now_v7();
let team = Uuid::now_v7();
let mission = Uuid::now_v7();
let user = Uuid::now_v7();
sqlx::query("INSERT INTO workspaces (id, name, plan) VALUES ($1,'w','free')")
.bind(ws.as_uuid())
.execute(pool)
.await
.expect("workspace");
// `agents.managed_by` is NOT NULL and a real FK, so an owner has to
// exist before any agent does.
sqlx::query(
"INSERT INTO users (id, workspace_id, email, role, display_name)
VALUES ($1,$2,$3,'owner','Owner')",
)
.bind(user)
.bind(ws.as_uuid())
.bind(format!("o-{}@example.test", &user.to_string()[..8]))
.execute(pool)
.await
.expect("user");
sqlx::query(
"INSERT INTO agents
(id, workspace_id, name, job_title, system_prompt, avatar, accent,
wallpaper, managed_by, status)
VALUES ($1,$2,'Tomasz','researcher','','','#fff','',$3,'online')",
)
.bind(agent)
.bind(ws.as_uuid())
.bind(user)
.execute(pool)
.await
.expect("agent");
sqlx::query(
"INSERT INTO teams (id, workspace_id, name, kind, graph, status, lifecycle, mcp_bundles)
VALUES ($1,$2,'crew','crew','{}'::jsonb,'active','permanent','{}')",
)
.bind(team)
.bind(ws.as_uuid())
.execute(pool)
.await
.expect("team");
sqlx::query(
"INSERT INTO team_members (team_id, node_id, claw_id, role)
VALUES ($1,'n1',$2,'researcher')",
)
.bind(team)
.bind(agent)
.execute(pool)
.await
.expect("member");
sqlx::query(
"INSERT INTO missions
(id, workspace_id, title, template_kind, schedule, status, config,
runtime_kind, created_at, updated_at, completed_at)
VALUES ($1,$2,'JEPA Research','research_only','{}'::jsonb,'completed',
'{}'::jsonb,'zeroclaw',
now() - interval '2 days',
now() - interval '2 days',
now() - interval '2 days' + interval '30 minutes')",
)
.bind(mission)
.bind(ws.as_uuid())
.execute(pool)
.await
.expect("mission");
sqlx::query(
"INSERT INTO mission_teams (mission_id, team_id, purpose) VALUES ($1,$2,'crew')",
)
.bind(mission)
.bind(team)
.execute(pool)
.await
.expect("mission_team");
// Usage rows land INSIDE the mission's window, because the window is
// how they are attributed — `usage_events` carries no mission id.
for (tin, tout, credits) in [(100_i32, 900_i32, 4.0_f64), (200, 800, 5.0)] {
sqlx::query(
"INSERT INTO usage_events
(workspace_id, agent_id, kind, tokens_in, tokens_out, credits, created_at)
VALUES ($1,$2,'llm_tokens',$3,$4,$5,
now() - interval '2 days' + interval '10 minutes')",
)
.bind(ws.as_uuid())
.bind(agent)
.bind(tin)
.bind(tout)
.bind(credits)
.execute(pool)
.await
.expect("usage");
}
for owner in [Some(agent), Some(agent), Some(agent), None] {
sqlx::query(
"INSERT INTO mission_events (mission_id, agent_id, kind, target, detail)
VALUES ($1,$2,'tool.call','Bash','{}'::jsonb)",
)
.bind(mission)
.bind(owner)
.execute(pool)
.await
.expect("event");
}
(ws, agent)
}
#[tokio::test]
async fn a_finished_mission_still_answers_what_the_agent_did() {
let pool = cm_testkit::test_pool().await;
let (ws, agent) = seed(&pool).await;
let got = super::agent_last_run(&pool, ws).await;
let lr = got
.get(&agent.to_string())
.expect("the agent's last finished mission must be found");
assert_eq!(lr.title, "JEPA Research");
assert_eq!(lr.status, "completed");
assert_eq!(lr.tokens, 2000, "tokens_in + tokens_out over both turns");
assert_eq!(lr.credits, 9.0);
assert_eq!(
lr.tool_calls, 3,
"only this agent's calls — the unattributed row belongs to no one \
and must not be credited to them"
);
}
/// A running mission is the live feed's business. Reporting it here would
/// put a current number behind a card the UI labels "last run".
#[tokio::test]
async fn a_mission_still_running_is_not_reported_as_a_last_run() {
let pool = cm_testkit::test_pool().await;
let (ws, agent) = seed(&pool).await;
sqlx::query(
"UPDATE missions SET status='running', completed_at=NULL WHERE workspace_id=$1",
)
.bind(ws.as_uuid())
.execute(&pool)
.await
.expect("update");
assert!(
super::agent_last_run(&pool, ws)
.await
.get(&agent.to_string())
.is_none(),
"only completed and failed missions are history"
);
}
}
@@ -10,7 +10,8 @@ import { useRouter } from "next/navigation";
import { Activity, Brain, Camera } from "lucide-react"; import { Activity, Brain, Camera } from "lucide-react";
import type { DemoAgent } from "@/lib/dashboard-demo"; import type { DemoAgent } from "@/lib/dashboard-demo";
import { useAgentTelemetry, useLiveEvent } from "@/lib/live/useClawmatesLive"; import { useAgentLastRun, useAgentTelemetry, useLiveEvent } from "@/lib/live/useClawmatesLive";
import type { TaxonomyPayload } from "@/lib/live/taxonomy";
import { AnatomyCard, mono, type RawBrain } from "./anatomy-cards"; import { AnatomyCard, mono, type RawBrain } from "./anatomy-cards";
import { AnatomyGrid } from "./AnatomyGrid"; import { AnatomyGrid } from "./AnatomyGrid";
import { AvatarModal } from "./AvatarModal"; import { AvatarModal } from "./AvatarModal";
@@ -29,10 +30,17 @@ function Sparkline({ values, color }: { values: number[]; color: string }) {
); );
} }
function MetricTile({ label, value, unit, tint, spark }: { label: string; value: string; unit?: string; tint?: string; spark?: number[] }) { // `historical` is not decoration. These tiles promise a live reading — tokens
// this minute, credits this hour — and an agent whose mission ended two days
// ago has none. Showing its last run unlabelled would answer a question about
// NOW with a number about THEN, which is worse than the zero it replaces.
function MetricTile({ label, value, unit, tint, spark, historical }: { label: string; value: string; unit?: string; tint?: string; spark?: number[]; historical?: boolean }) {
return ( return (
<div style={{ flex: 1, minWidth: 0, borderRadius: 12, background: "#0d0d10", border: `1px solid ${tint ? `${tint}45` : "rgba(255,255,255,.07)"}`, padding: "10px 13px" }}> <div style={{ flex: 1, minWidth: 0, borderRadius: 12, background: "#0d0d10", border: `1px solid ${tint ? `${tint}45` : "rgba(255,255,255,.07)"}`, padding: "10px 13px", opacity: historical ? 0.82 : 1 }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#5a5a62", marginBottom: 5 }}>{label}</div> <div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".1em", color: "#5a5a62", marginBottom: 5, display: "flex", alignItems: "center", gap: 6 }}>
<span>{label}</span>
{historical ? <span style={{ fontSize: 8.5, letterSpacing: ".08em", color: "#6a6a72", border: "1px solid rgba(255,255,255,.12)", borderRadius: 4, padding: "1px 4px" }}>LAST RUN</span> : null}
</div>
<div style={{ display: "flex", alignItems: "baseline", gap: 5 }}> <div style={{ display: "flex", alignItems: "baseline", gap: 5 }}>
<span style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, color: tint ?? "#f3f3f5" }}>{value}</span> <span style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, color: tint ?? "#f3f3f5" }}>{value}</span>
{unit ? <span style={{ fontFamily: mono, fontSize: 10, color: "#6a6a72" }}>{unit}</span> : null} {unit ? <span style={{ fontFamily: mono, fontSize: 10, color: "#6a6a72" }}>{unit}</span> : null}
@@ -45,7 +53,7 @@ function MetricTile({ label, value, unit, tint, spark }: { label: string; value:
// ── LIVE column ────────────────────────────────────────────────────────────── // ── LIVE column ──────────────────────────────────────────────────────────────
type Step = { label: string; state: "done" | "active" | "pending" }; type Step = { label: string; state: "done" | "active" | "pending" };
function WorkingOnNow({ agentId }: { agentId: string }) { function WorkingOnNow({ agentId, lastRun }: { agentId: string; lastRun?: TaxonomyPayload<"agent.last_run"> | null }) {
const [task, setTask] = useState<{ title: string; steps: Step[] } | null>(null); const [task, setTask] = useState<{ title: string; steps: Step[] } | null>(null);
useLiveEvent("agent.task.update", (d) => { useLiveEvent("agent.task.update", (d) => {
if (d.agentId === agentId) setTask({ title: d.title, steps: d.steps ?? [] }); if (d.agentId === agentId) setTask({ title: d.title, steps: d.steps ?? [] });
@@ -54,7 +62,23 @@ function WorkingOnNow({ agentId }: { agentId: string }) {
return ( return (
<AnatomyCard tint="#5ec8d8" label="WORKING ON NOW" icon={<Activity size={15} />}> <AnatomyCard tint="#5ec8d8" label="WORKING ON NOW" icon={<Activity size={15} />}>
{!task ? ( {!task ? (
<span style={{ fontFamily: mono, fontSize: 12.5, color: "#6a6a72" }}>idle — no active task. Live work streams here as the agent runs.</span> // Idle stays idle — the card does not pretend a finished mission is
// running. It just stops being a dead end: what the agent last did, and
// how long ago, instead of one line of nothing.
lastRun ? (
<div>
<div style={{ fontFamily: mono, fontSize: 11, letterSpacing: ".06em", color: "#6a6a72", marginBottom: 6 }}>
IDLE · LAST RUN {ago(lastRun.endedAt).toUpperCase()}
</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "#e6e6ea" }}>{lastRun.title}</div>
<div style={{ fontFamily: mono, fontSize: 12, color: "#8a8a92", marginTop: 5 }}>
{fmt(lastRun.toolCalls)} tool calls · {fmt(lastRun.tokens)} tokens ·{" "}
<span style={{ color: lastRun.status === "completed" ? "#5fd08a" : "#e8756a" }}>{lastRun.status}</span>
</div>
</div>
) : (
<span style={{ fontFamily: mono, fontSize: 12.5, color: "#6a6a72" }}>idle — no active task. Live work streams here as the agent runs.</span>
)
) : ( ) : (
<div> <div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: "#e6e6ea", marginBottom: task.steps.length ? 9 : 0 }}>{task.title}</div> <div style={{ fontSize: 13.5, fontWeight: 600, color: "#e6e6ea", marginBottom: task.steps.length ? 9 : 0 }}>{task.title}</div>
@@ -148,7 +172,7 @@ function ActivityTile({ agentId }: { agentId: string }) {
// Full-width row — same throughput data as the old top tile, but wrapped // Full-width row — same throughput data as the old top tile, but wrapped
// in an AnatomyCard so the sparkline gets real room to breathe. Reads // in an AnatomyCard so the sparkline gets real room to breathe. Reads
// telemetry directly so the parent only has to pass the agent id. // telemetry directly so the parent only has to pass the agent id.
function ThroughputCard({ agentId, value }: { agentId: string; value: number }) { function ThroughputCard({ agentId, value, historical, historyLabel }: { agentId: string; value: number; historical?: boolean; historyLabel?: string }) {
const buf = useRef<number[]>([]); const buf = useRef<number[]>([]);
const [spark, setSpark] = useState<number[]>([]); const [spark, setSpark] = useState<number[]>([]);
useEffect(() => { useEffect(() => {
@@ -171,15 +195,39 @@ function ThroughputCard({ agentId, value }: { agentId: string; value: number })
<AnatomyCard tint="#5ec8d8" label="THROUGHPUT" icon={<Activity size={15} />} key={`throughput-${agentId}`}> <AnatomyCard tint="#5ec8d8" label="THROUGHPUT" icon={<Activity size={15} />} key={`throughput-${agentId}`}>
<div style={{ display: "flex", alignItems: "baseline", gap: 8 }}> <div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
<div style={{ fontSize: 28, fontWeight: 700, color: "#e6e6ea", letterSpacing: "-.02em" }}>{fmt(value)}</div> <div style={{ fontSize: 28, fontWeight: 700, color: "#e6e6ea", letterSpacing: "-.02em" }}>{fmt(value)}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>tok/min</div> {/* The unit CHANGES with the source. A total billed over a whole mission
is not a rate, and labelling it "tok/min" would be a wrong reading
rather than an old one. */}
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>{historical ? "tokens · last run" : "tok/min"}</div>
</div> </div>
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: "100%", height: 84, marginTop: 6 }}> {historical && historyLabel ? (
<polyline points={points} fill="none" stroke="#5ec8d8" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" /> <div style={{ fontFamily: mono, fontSize: 10.5, color: "#6a6a72", marginTop: 3 }}>{historyLabel}</div>
</svg> ) : null}
{/* No sparkline for a historical total: a flat line drawn from one
repeated number reads as "measured and steady" when nothing was
measured at all. */}
{historical ? null : (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: "100%", height: 84, marginTop: 6 }}>
<polyline points={points} fill="none" stroke="#5ec8d8" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)}
</AnatomyCard> </AnatomyCard>
); );
} }
// "2 days ago" from unix seconds. Coarse on purpose: the point is that the
// number is OLD, not exactly how old.
function ago(unixSeconds?: number | null): string {
if (!unixSeconds) return "";
const secs = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
if (secs < 90) return "just now";
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 36) return `${hrs}h ago`;
return `${Math.round(hrs / 24)}d ago`;
}
// ── Command center ─────────────────────────────────────────────────────────── // ── Command center ───────────────────────────────────────────────────────────
export function ClawCommandCenter({ export function ClawCommandCenter({
agent, agent,
@@ -203,7 +251,14 @@ export function ClawCommandCenter({
const shownAvatar = imgUrl ?? avatarUrl ?? null; const shownAvatar = imgUrl ?? avatarUrl ?? null;
const tele = useAgentTelemetry(agent.id); const tele = useAgentTelemetry(agent.id);
const lastRun = useAgentLastRun(agent.id);
const doors = tele?.doorsPending ?? 0; const doors = tele?.doorsPending ?? 0;
// Live first, always. The historical value only appears where the live one is
// genuinely nothing — an agent mid-turn must never see a stale figure.
const liveSpend = tele?.costPerHr ?? 0;
const spendIsHistorical = liveSpend === 0 && !!lastRun;
const liveTokens = tele?.tokensPerMin ?? 0;
const tokensAreHistorical = liveTokens === 0 && !!lastRun;
// Level up now lives at the bottom of the Agents sidebar (Dashboard), where // Level up now lives at the bottom of the Agents sidebar (Dashboard), where
// it sits next to the agent you picked rather than in this header. // it sits next to the agent you picked rather than in this header.
@@ -242,7 +297,13 @@ export function ClawCommandCenter({
<MetricTile label="DOORS" value={`${doors}`} unit={doors > 0 ? "pending" : "clear"} tint={doors > 0 ? "#e8b465" : "#5fd08a"} /> <MetricTile label="DOORS" value={`${doors}`} unit={doors > 0 ? "pending" : "clear"} tint={doors > 0 ? "#e8b465" : "#5fd08a"} />
<MetricTile label="MEMORY" value={fmt(brain?.stats.memories ?? 0)} unit="notes" /> <MetricTile label="MEMORY" value={fmt(brain?.stats.memories ?? 0)} unit="notes" />
<MetricTile label="LOOPS" value={`${tele?.loops ?? 0}`} unit="active" tint="#5fd08a" /> <MetricTile label="LOOPS" value={`${tele?.loops ?? 0}`} unit="active" tint="#5fd08a" />
<MetricTile label="SPEND" value={(tele?.costPerHr ?? 0).toFixed(2)} unit="cr/hr" tint="#e8b465" /> <MetricTile
label="SPEND"
value={(spendIsHistorical ? lastRun!.credits : liveSpend).toFixed(2)}
unit={spendIsHistorical ? "cr total" : "cr/hr"}
tint="#e8b465"
historical={spendIsHistorical}
/>
<div style={{ gridColumn: "1 / -1" }}> <div style={{ gridColumn: "1 / -1" }}>
<ActivityTile key={`act-tile-${agent.id}`} agentId={agent.id} /> <ActivityTile key={`act-tile-${agent.id}`} agentId={agent.id} />
</div> </div>
@@ -254,9 +315,15 @@ export function ClawCommandCenter({
Anatomy Grid (Dot Brain and its neighbours). Scrolls as one Anatomy Grid (Dot Brain and its neighbours). Scrolls as one
column; the computer panel takes its own scroll to the right. */} column; the computer panel takes its own scroll to the right. */}
<div style={{ flex: "1 1 0", minHeight: 0, display: "flex", flexDirection: "column", gap: 14, padding: "4px 22px 18px", overflowY: "auto" }}> <div style={{ flex: "1 1 0", minHeight: 0, display: "flex", flexDirection: "column", gap: 14, padding: "4px 22px 18px", overflowY: "auto" }}>
<WorkingOnNow key={`won-${agent.id}`} agentId={agent.id} /> <WorkingOnNow key={`won-${agent.id}`} agentId={agent.id} lastRun={lastRun} />
<ReasoningStream key={`rs-${agent.id}`} agentId={agent.id} /> <ReasoningStream key={`rs-${agent.id}`} agentId={agent.id} />
<ThroughputCard key={`tp-${agent.id}`} agentId={agent.id} value={tele?.tokensPerMin ?? 0} /> <ThroughputCard
key={`tp-${agent.id}`}
agentId={agent.id}
value={tokensAreHistorical ? lastRun!.tokens : liveTokens}
historical={tokensAreHistorical}
historyLabel={tokensAreHistorical ? `${lastRun!.title} · ${ago(lastRun!.endedAt)}` : undefined}
/>
<AnatomyGrid agent={agent} brain={brain} onSaved={onToolsChanged} onAddTool={() => setAddToolOpen(true)} /> <AnatomyGrid agent={agent} brain={brain} onSaved={onToolsChanged} onAddTool={() => setAddToolOpen(true)} />
</div> </div>
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { STATEFUL_TYPES, TAXONOMY_TYPES, stateKey } from "./taxonomy";
describe("agent.last_run", () => {
// The metric band's historical half. It is retained and replayed, so a late
// subscriber paints a finished mission at once instead of waiting out the
// once-a-minute refresh.
it("is a declared, retained taxonomy type", () => {
expect(TAXONOMY_TYPES).toContain("agent.last_run");
expect(STATEFUL_TYPES.has("agent.last_run")).toBe(true);
});
// The bug this guards is silent and looks like a UI glitch: one shared key
// lets the last agent in the roster overwrite every other agent's retained
// summary, and a late subscriber then paints ONE agent's last run onto all
// of them. Every card would show plausible numbers belonging to someone else.
it("retains one value per agent, not one per type", () => {
const a = stateKey("agent.last_run", {
agentId: "agent-a", missionId: "m1", title: "JEPA Research",
status: "completed", tokens: 21697, credits: 22, toolCalls: 96,
});
const b = stateKey("agent.last_run", {
agentId: "agent-b", missionId: "m1", title: "JEPA Research",
status: "completed", tokens: 4304, credits: 5, toolCalls: 5,
});
expect(a).not.toBe(b);
expect(a).toContain("agent-a");
});
// Live and historical must not collide in the retain map either: they are
// deliberately separate events so the card can label which one it shows.
it("does not share a retain key with live telemetry", () => {
const live = stateKey("telemetry", { agentId: "agent-a", tokensPerMin: 0 });
const past = stateKey("agent.last_run", {
agentId: "agent-a", missionId: "m1", title: "t",
status: "completed", tokens: 1, credits: 1, toolCalls: 1,
});
expect(live).not.toBe(past);
});
});
+30 -1
View File
@@ -137,6 +137,23 @@ export interface TaxonomyEvents {
/** Top-bar pills + Observe System telemetry strip. With `agentId` it's the /** Top-bar pills + Observe System telemetry strip. With `agentId` it's the
* per-agent slice for the command-center metric band; without, workspace-wide. */ * per-agent slice for the command-center metric band; without, workspace-wide. */
telemetry: { agentId?: string; tokensPerMin?: number; costPerHr?: number; loops?: number; doorsPending?: number }; telemetry: { agentId?: string; tokensPerMin?: number; costPerHr?: number; loops?: number; doorsPending?: number };
/** What an agent did on its last FINISHED mission. Deliberately separate from
* `telemetry`: that one is live (tokens/minute, credits/hour, active
* routines, pending approvals) and is correctly zero once a mission ends.
* Folding history into those tiles would show a two-day-old number where the
* UI promises a live one, so the two travel apart and the card labels which
* it is showing. */
"agent.last_run": {
agentId: string;
missionId: string;
title: string;
status: string;
/** Unix seconds, or null if the mission never recorded an end. */
endedAt?: number | null;
tokens: number;
credits: number;
toolCalls: number;
};
/** Observe System → ROUTINES & LOOPS; the scheduler view. */ /** Observe System → ROUTINES & LOOPS; the scheduler view. */
"routine.update": { "routine.update": {
routineId: string; routineId: string;
@@ -177,6 +194,7 @@ export const TAXONOMY_TYPES: TaxonomyType[] = [
"mission.benchmark", "mission.benchmark",
"topology.update", "topology.update",
"telemetry", "telemetry",
"agent.last_run",
"routine.update", "routine.update",
]; ];
@@ -185,6 +203,9 @@ export const TAXONOMY_TYPES: TaxonomyType[] = [
* touches, messages) which are not replayed. */ * touches, messages) which are not replayed. */
export const STATEFUL_TYPES = new Set<TaxonomyType>([ export const STATEFUL_TYPES = new Set<TaxonomyType>([
"agent.status", "agent.status",
// Historical and per-agent: a late subscriber must paint it at once rather
// than wait out the once-a-minute refresh.
"agent.last_run",
"agent.task.update", "agent.task.update",
"agent.memory", "agent.memory",
"node.activity", "node.activity",
@@ -198,7 +219,15 @@ export const STATEFUL_TYPES = new Set<TaxonomyType>([
/** The replay key per stateful event (one retained value per agent/node/routine). */ /** The replay key per stateful event (one retained value per agent/node/routine). */
export function stateKey<T extends TaxonomyType>(type: T, d: TaxonomyPayload<T>): string { export function stateKey<T extends TaxonomyType>(type: T, d: TaxonomyPayload<T>): string {
if (type === "agent.status" || type === "agent.task.update" || type === "agent.memory") if (
type === "agent.status" ||
type === "agent.task.update" ||
type === "agent.memory" ||
// Per AGENT, not per type: one shared key would let the last agent in the
// roster overwrite every other agent's retained summary, and a late
// subscriber would paint one agent's last run onto all of them.
type === "agent.last_run"
)
return `${type}:${(d as TaxonomyEvents["agent.status"]).agentId}`; return `${type}:${(d as TaxonomyEvents["agent.status"]).agentId}`;
if (type === "node.activity") return `${type}:${(d as TaxonomyEvents["node.activity"]).nodeId}`; if (type === "node.activity") return `${type}:${(d as TaxonomyEvents["node.activity"]).nodeId}`;
if (type === "routine.update") return `${type}:${(d as TaxonomyEvents["routine.update"]).routineId}`; if (type === "routine.update") return `${type}:${(d as TaxonomyEvents["routine.update"]).routineId}`;
+15
View File
@@ -285,3 +285,18 @@ function runSynthetic(emit: Emit): () => void {
); );
return () => timers.forEach(clearInterval); return () => timers.forEach(clearInterval);
} }
/** The agent's last FINISHED mission, for the metric band's historical half.
* Same shape of guard as `useAgentTelemetry`: tagged with its agentId so a
* previous agent's summary can never paint onto the one now selected. */
export function useAgentLastRun(agentId: string | null): TaxonomyPayload<"agent.last_run"> | null {
const client = useClawmatesLive();
const [slice, setSlice] = useState<{ agentId: string; data: TaxonomyPayload<"agent.last_run"> } | null>(null);
useEffect(() => {
if (!agentId) return;
return client.on("agent.last_run", (d) => {
if (d.agentId === agentId) setSlice({ agentId, data: d });
});
}, [client, agentId]);
return slice && slice.agentId === agentId ? slice.data : null;
}