| 1 | //! Receipts-only projection of every agent that ran this session (#5479). |
| 2 | //! |
| 3 | //! Separated from roster glyphs and terminal rendering so engine events, |
| 4 | //! protocol parity, and headless audit records can track worker receipts |
| 5 | //! without depending on TUI layout modules. |
| 6 | //! |
| 7 | //! ## The truth rule |
| 8 | //! |
| 9 | //! Every number here is an `Option`, and `None` renders as `—`, never as `0`. |
| 10 | //! The distinction is the whole point: "this worker reported 96,300 input |
| 11 | //! tokens" and "no usage receipt exists for this worker" are different facts, |
| 12 | //! and a rail that prints `0` for the second one is lying in the direction that |
| 13 | //! makes Codewhale look cheap. Nothing here estimates, derives a token count |
| 14 | //! from text, or back-fills a missing receipt — values come from |
| 15 | //! `AgentRunUsage`, which is populated from immutable per-response route |
| 16 | //! audits, or they are absent. |
| 17 | //! |
| 18 | //! A finished agent keeps the numbers it finished with: rows are built from the |
| 19 | //! retained worker record, never recomputed from live state. |
| 20 | |
| 21 | use serde::{Deserialize, Serialize}; |
| 22 | |
| 23 | use crate::tools::subagent::{AgentWorkerRecord, AgentWorkerStatus}; |
| 24 | |
| 25 | /// What a row is doing, collapsed to one glanceable state. |
| 26 | /// |
| 27 | /// Deliberately coarser than `AgentWorkerStatus`: the rail needs a glyph and |
| 28 | /// a sort rank, not the full lifecycle. The precise status stays on the row. |
| 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 30 | #[serde(rename_all = "snake_case")] |
| 31 | pub enum RosterState { |
| 32 | Running, |
| 33 | Waiting, |
| 34 | /// Settled because the parent's turn ended before this child did (#5906). |
| 35 | /// |
| 36 | /// Distinct from `Waiting`, which means a person can answer it. Nothing |
| 37 | /// will answer a parked husk; it is continued with `resume_from` or |
| 38 | /// dismissed with `cancel`. |
| 39 | Parked, |
| 40 | Done, |
| 41 | Failed, |
| 42 | Cancelled, |
| 43 | } |
| 44 | |
| 45 | impl RosterState { |
| 46 | #[must_use] |
| 47 | pub const fn is_terminal(self) -> bool { |
| 48 | matches!(self, Self::Done | Self::Failed | Self::Cancelled) |
| 49 | } |
| 50 | |
| 51 | #[must_use] |
| 52 | pub const fn as_str(self) -> &'static str { |
| 53 | match self { |
| 54 | Self::Running => "running", |
| 55 | Self::Waiting => "waiting", |
| 56 | Self::Parked => "parked", |
| 57 | Self::Done => "done", |
| 58 | Self::Failed => "failed", |
| 59 | Self::Cancelled => "cancelled", |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | /// Single-width glyph. Filled = attention, hollow = at rest. |
| 64 | #[must_use] |
| 65 | pub const fn glyph(self) -> &'static str { |
| 66 | match self { |
| 67 | Self::Running => "●", |
| 68 | Self::Waiting => "◐", |
| 69 | // Hollow, dotted: at rest but not finished. |
| 70 | Self::Parked => "◌", |
| 71 | Self::Done => "○", |
| 72 | Self::Failed => "✗", |
| 73 | Self::Cancelled => "⊘", |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | /// Row state for one retained record. |
| 78 | /// |
| 79 | /// `parked_at_turn_end` is checked first and outranks the worker status: |
| 80 | /// a parked child settles as `WaitingForUser` or `Interrupted` like any |
| 81 | /// other, and only this flag separates it from a child that really asked |
| 82 | /// (#5906). |
| 83 | #[must_use] |
| 84 | pub const fn from_record(record: &AgentWorkerRecord) -> Self { |
| 85 | if record.parked_at_turn_end { |
| 86 | return Self::Parked; |
| 87 | } |
| 88 | Self::from_worker(record.status) |
| 89 | } |
| 90 | |
| 91 | #[must_use] |
| 92 | pub const fn from_worker(status: AgentWorkerStatus) -> Self { |
| 93 | match status { |
| 94 | AgentWorkerStatus::Queued |
| 95 | | AgentWorkerStatus::Starting |
| 96 | | AgentWorkerStatus::Running |
| 97 | | AgentWorkerStatus::ModelWait |
| 98 | | AgentWorkerStatus::RunningTool => Self::Running, |
| 99 | AgentWorkerStatus::WaitingForUser => Self::Waiting, |
| 100 | AgentWorkerStatus::Completed => Self::Done, |
| 101 | AgentWorkerStatus::Failed => Self::Failed, |
| 102 | AgentWorkerStatus::Cancelled | AgentWorkerStatus::Interrupted => Self::Cancelled, |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// One agent's row. Every optional field means "no receipt", not "zero". |
| 108 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 109 | pub struct AgentRosterRow { |
| 110 | pub worker_id: String, |
| 111 | /// Session name, else role, else the fleet type — whichever the user named. |
| 112 | pub display_name: String, |
| 113 | pub model: String, |
| 114 | pub state: RosterState, |
| 115 | pub status: AgentWorkerStatus, |
| 116 | /// The agent's outcome, current step or last tool, in one line. `None` |
| 117 | /// when the worker has not reported an event yet. |
| 118 | pub activity: Option<String>, |
| 119 | /// What a settled worker produced, in full (its result, or its error). The |
| 120 | /// row shows a one-line headline; the focused view shows this, so the |
| 121 | /// answer is never only available cut off (#6565). `None` while live. |
| 122 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 123 | pub outcome: Option<String>, |
| 124 | /// Wall time: elapsed for a live agent, final duration for a finished one. |
| 125 | pub millis: Option<u64>, |
| 126 | pub input_tokens: Option<u64>, |
| 127 | pub output_tokens: Option<u64>, |
| 128 | pub cost_microusd: Option<u64>, |
| 129 | pub steps_taken: u32, |
| 130 | /// Set when this agent was spawned by another; the parent aggregates it. |
| 131 | pub parent_run_id: Option<String>, |
| 132 | pub run_id: String, |
| 133 | } |
| 134 | |
| 135 | impl AgentRosterRow { |
| 136 | /// `n/m done` for a workflow parent, from its children's terminal states. |
| 137 | #[must_use] |
| 138 | pub fn workflow_progress(&self, rows: &[Self]) -> Option<(usize, usize)> { |
| 139 | let children = rows |
| 140 | .iter() |
| 141 | .filter(|row| row.parent_run_id.as_deref() == Some(self.run_id.as_str())) |
| 142 | .collect::<Vec<_>>(); |
| 143 | if children.is_empty() { |
| 144 | return None; |
| 145 | } |
| 146 | let done = children |
| 147 | .iter() |
| 148 | .filter(|row| row.state.is_terminal()) |
| 149 | .count(); |
| 150 | Some((done, children.len())) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | /// Build the roster from retained worker records. |
| 155 | /// |
| 156 | /// `now_ms` is passed in rather than read from the clock so the projection is a |
| 157 | /// pure function — the caller supplies the same instant it renders with, and |
| 158 | /// tests get deterministic elapsed values. |
| 159 | #[must_use] |
| 160 | pub fn build_agent_roster(records: &[AgentWorkerRecord], now_ms: u64) -> Vec<AgentRosterRow> { |
| 161 | let mut rows: Vec<AgentRosterRow> = records |
| 162 | .iter() |
| 163 | .map(|record| row_from_record(record, now_ms)) |
| 164 | .collect(); |
| 165 | // Oldest first: the rail is a history of the session, and a list that |
| 166 | // reorders itself as agents finish is unreadable while you are watching it. |
| 167 | rows.sort_by(|a, b| a.worker_id.cmp(&b.worker_id)); |
| 168 | rows.sort_by_key(|row| creation_key(records, &row.worker_id)); |
| 169 | // ...except parked husks, which sink below everything still live or |
| 170 | // answerable (#5906). They are the one class of row the operator is not |
| 171 | // meant to scan past to find real work, and the sort is stable so the |
| 172 | // history order survives inside each group. |
| 173 | rows.sort_by_key(|row| row.state == RosterState::Parked); |
| 174 | rows |
| 175 | } |
| 176 | |
| 177 | fn creation_key(records: &[AgentWorkerRecord], worker_id: &str) -> u64 { |
| 178 | records |
| 179 | .iter() |
| 180 | .find(|record| record.spec.worker_id == worker_id) |
| 181 | .map_or(u64::MAX, |record| record.created_at_ms) |
| 182 | } |
| 183 | |
| 184 | #[must_use] |
| 185 | pub fn row_from_record(record: &AgentWorkerRecord, now_ms: u64) -> AgentRosterRow { |
| 186 | let state = RosterState::from_record(record); |
| 187 | AgentRosterRow { |
| 188 | worker_id: record.spec.worker_id.clone(), |
| 189 | display_name: display_name(record), |
| 190 | model: record.spec.model.clone(), |
| 191 | state, |
| 192 | status: record.status, |
| 193 | activity: activity_line(record), |
| 194 | outcome: settled_outcome(record).map(str::to_string), |
| 195 | millis: wall_millis(record, now_ms), |
| 196 | input_tokens: record.usage.input_tokens, |
| 197 | output_tokens: record.usage.output_tokens, |
| 198 | cost_microusd: record.usage.cost_microusd, |
| 199 | steps_taken: record.steps_taken, |
| 200 | parent_run_id: record.parent_run_id.clone(), |
| 201 | run_id: record.spec.run_id.clone(), |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | #[must_use] |
| 206 | pub fn display_name(record: &AgentWorkerRecord) -> String { |
| 207 | record |
| 208 | .spec |
| 209 | .session_name |
| 210 | .clone() |
| 211 | .or_else(|| { |
| 212 | record |
| 213 | .spec |
| 214 | .child_route |
| 215 | .as_ref() |
| 216 | .and_then(|route| route.resolved_profile_id.clone()) |
| 217 | .filter(|profile| !profile.trim().is_empty()) |
| 218 | }) |
| 219 | .or_else(|| record.spec.role.clone()) |
| 220 | .unwrap_or_else(|| record.spec.agent_type.as_str().to_string()) |
| 221 | } |
| 222 | |
| 223 | /// The agent's outcome, current step or last tool, in one line. |
| 224 | /// |
| 225 | /// A settled worker leads with what it produced (#6565): a finished one shows |
| 226 | /// the headline of its result, and a failed or cancelled one its error, not |
| 227 | /// the last tool it happened to call. A live worker's preference order is |
| 228 | /// most-specific-first: the newest event naming a tool, then the newest event |
| 229 | /// carrying a message, then the worker's latest message. |
| 230 | #[must_use] |
| 231 | pub fn activity_line(record: &AgentWorkerRecord) -> Option<String> { |
| 232 | if let Some(headline) = settled_outcome(record).and_then(result_headline) { |
| 233 | return Some(one_line(&headline)); |
| 234 | } |
| 235 | let from_events = record.events.iter().rev().find_map(|event| { |
| 236 | event |
| 237 | .tool_name |
| 238 | .as_ref() |
| 239 | .map(|tool| match event.step { |
| 240 | Some(step) => format!("step {step} · {tool}"), |
| 241 | None => tool.clone(), |
| 242 | }) |
| 243 | .or_else(|| event.message.clone()) |
| 244 | }); |
| 245 | from_events |
| 246 | .or_else(|| record.latest_message.clone()) |
| 247 | .or_else(|| record.result_summary.clone()) |
| 248 | .map(|line| one_line(&line)) |
| 249 | } |
| 250 | |
| 251 | /// What a settled worker produced, in full: its result when it finished, its |
| 252 | /// error (else its partial result) when it failed or was stopped. `None` while |
| 253 | /// it is live or parked. |
| 254 | #[must_use] |
| 255 | pub fn settled_outcome(record: &AgentWorkerRecord) -> Option<&str> { |
| 256 | let outcome = match RosterState::from_record(record) { |
| 257 | RosterState::Done => record.result_summary.as_deref(), |
| 258 | RosterState::Failed | RosterState::Cancelled => { |
| 259 | record.error.as_deref().or(record.result_summary.as_deref()) |
| 260 | } |
| 261 | RosterState::Running | RosterState::Waiting | RosterState::Parked => None, |
| 262 | }; |
| 263 | outcome.filter(|text| !text.trim().is_empty()) |
| 264 | } |
| 265 | |
| 266 | /// The line of an agent's result that says what it found (#6565). |
| 267 | /// |
| 268 | /// A report usually opens with scaffolding: a `## Summary` heading, a rule, a |
| 269 | /// code fence, or the machine-readable completion envelope. Showing its first |
| 270 | /// line showed the scaffolding and hid the answer. This skips blank lines, the |
| 271 | /// `<codewhale:…>` envelope, headings, rule-only lines and fence markers, and |
| 272 | /// returns the first sentence of the first line of prose. A result that is |
| 273 | /// only headings still yields its first heading, without the `#` marks. |
| 274 | /// `None` when nothing is left. |
| 275 | /// |
| 276 | /// The headline is not bounded here; a one-row surface bounds it with |
| 277 | /// [`one_line`], which marks the cut with `…`. The full result stays one Enter |
| 278 | /// away in the agent's focused view. |
| 279 | #[must_use] |
| 280 | pub fn result_headline(text: &str) -> Option<String> { |
| 281 | let is_rule = |line: &str| { |
| 282 | line.chars().filter(|c| !c.is_whitespace()).count() >= 3 |
| 283 | && line |
| 284 | .chars() |
| 285 | .all(|c| matches!(c, '-' | '*' | '_' | '=') || c.is_whitespace()) |
| 286 | }; |
| 287 | let is_fence = |line: &str| line.starts_with("```") || line.starts_with("~~~"); |
| 288 | let candidates = || { |
| 289 | text.lines() |
| 290 | .map(str::trim) |
| 291 | .filter(|line| !line.is_empty() && !line.starts_with("<codewhale:")) |
| 292 | .filter(|line| !is_rule(line) && !is_fence(line)) |
| 293 | }; |
| 294 | let line = match candidates().find(|line| !line.starts_with('#')) { |
| 295 | Some(line) => line, |
| 296 | None => candidates().next()?.trim_start_matches('#').trim(), |
| 297 | }; |
| 298 | let sentence = first_sentence(line).trim(); |
| 299 | (!sentence.is_empty()).then(|| sentence.to_string()) |
| 300 | } |
| 301 | |
| 302 | /// Up to and including the first sentence terminator followed by a space, so |
| 303 | /// `v0.10.1` or `foo.rs` inside a line does not end the sentence. CJK full |
| 304 | /// stops end it wherever they fall. |
| 305 | fn first_sentence(line: &str) -> &str { |
| 306 | let mut chars = line.char_indices().peekable(); |
| 307 | while let Some((index, c)) = chars.next() { |
| 308 | let end = index + c.len_utf8(); |
| 309 | if matches!(c, '。' | '!' | '?') { |
| 310 | return &line[..end]; |
| 311 | } |
| 312 | if matches!(c, '.' | '!' | '?') |
| 313 | && chars.peek().is_some_and(|(_, next)| next.is_whitespace()) |
| 314 | { |
| 315 | return &line[..end]; |
| 316 | } |
| 317 | } |
| 318 | line |
| 319 | } |
| 320 | |
| 321 | /// Collapse to a single line and bound it. Rail rows are one row. |
| 322 | #[must_use] |
| 323 | pub fn one_line(text: &str) -> String { |
| 324 | const MAX_CHARS: usize = 72; |
| 325 | let flattened = text.split_whitespace().collect::<Vec<_>>().join(" "); |
| 326 | if flattened.chars().count() <= MAX_CHARS { |
| 327 | return flattened; |
| 328 | } |
| 329 | let kept = flattened.chars().take(MAX_CHARS - 1).collect::<String>(); |
| 330 | format!("{kept}…") |
| 331 | } |
| 332 | |
| 333 | /// Elapsed for a live agent, final duration for a finished one. |
| 334 | /// |
| 335 | /// `None` when the worker has no start timestamp — a queued worker has not |
| 336 | /// started, and reporting `0s` would imply it had. |
| 337 | #[must_use] |
| 338 | pub fn wall_millis(record: &AgentWorkerRecord, now_ms: u64) -> Option<u64> { |
| 339 | let started = record.started_at_ms?; |
| 340 | let end = record.completed_at_ms.unwrap_or(now_ms); |
| 341 | Some(end.saturating_sub(started)) |
| 342 | } |
| 343 | |
| 344 | /// `3m 29s`, `12s`, `450ms`. Compact because it shares a row. |
| 345 | #[must_use] |
| 346 | pub fn format_duration(millis: u64) -> String { |
| 347 | if millis < 1_000 { |
| 348 | return format!("{millis}ms"); |
| 349 | } |
| 350 | let seconds = millis / 1_000; |
| 351 | if seconds < 60 { |
| 352 | return format!("{seconds}s"); |
| 353 | } |
| 354 | let minutes = seconds / 60; |
| 355 | let rest = seconds % 60; |
| 356 | if minutes < 60 { |
| 357 | return format!("{minutes}m {rest}s"); |
| 358 | } |
| 359 | format!("{}h {}m", minutes / 60, minutes % 60) |
| 360 | } |
| 361 | |
| 362 | /// `96.3k`, `1.2M`, `812`. Never rounds a real count to zero. |
| 363 | #[must_use] |
| 364 | pub fn format_tokens(tokens: u64) -> String { |
| 365 | if tokens < 1_000 { |
| 366 | return tokens.to_string(); |
| 367 | } |
| 368 | if tokens < 1_000_000 { |
| 369 | return format!("{:.1}k", tokens as f64 / 1_000.0); |
| 370 | } |
| 371 | format!("{:.1}M", tokens as f64 / 1_000_000.0) |
| 372 | } |
| 373 | |
| 374 | /// Usage totals across the roster, for a footer line. |
| 375 | /// |
| 376 | /// Returns `None` for a field when *no* row reported it — summing absent |
| 377 | /// receipts into `0` would restate the same lie the per-row rule forbids. |
| 378 | #[must_use] |
| 379 | pub fn roster_totals(rows: &[AgentRosterRow]) -> (Option<u64>, Option<u64>) { |
| 380 | fn total(values: impl Iterator<Item = Option<u64>>) -> Option<u64> { |
| 381 | let reported: Vec<u64> = values.flatten().collect(); |
| 382 | (!reported.is_empty()).then(|| reported.into_iter().fold(0u64, u64::saturating_add)) |
| 383 | } |
| 384 | ( |
| 385 | total(rows.iter().map(|row| row.input_tokens)), |
| 386 | total(rows.iter().map(|row| row.output_tokens)), |
| 387 | ) |
| 388 | } |
| 389 | |
| 390 | /// True when at least one row reported a usage receipt. Callers use this to |
| 391 | /// label the totals line honestly ("partial receipts") instead of implying the |
| 392 | /// number covers every agent. |
| 393 | #[must_use] |
| 394 | pub fn all_rows_have_usage(rows: &[AgentRosterRow]) -> bool { |
| 395 | !rows.is_empty() |
| 396 | && rows |
| 397 | .iter() |
| 398 | .all(|row| row.input_tokens.is_some() || row.output_tokens.is_some()) |
| 399 | } |
| 400 |