feat(world): the agent page can answer what an agent DID, not only what it is doing
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:
co-authored by
Claude Opus 5
parent
2f1a870949
commit
f26de3ba76
@@ -694,6 +694,96 @@ async fn agent_telemetry(
|
||||
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`.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct LiveQuery {
|
||||
@@ -715,6 +805,11 @@ pub async fn world_live(
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
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.
|
||||
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.
|
||||
@@ -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.
|
||||
//
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user