1use std::sync::Mutex;
14use std::time::Duration;
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Spent {
21 pub agent: String,
22 pub kind: String,
25 #[serde(default, skip_serializing_if = "String::is_empty")]
27 pub effort: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub subject: Option<i64>,
31 pub seconds: f64,
32 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
34 pub retry: bool,
35 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
37 pub fallback: bool,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub tokens: Option<u64>,
42 pub ok: bool,
44}
45
46static SPENT: Mutex<Vec<Spent>> = Mutex::new(Vec::new());
47
48pub fn record(call: Spent) {
50 crate::logdim!(
51 "{} {} call{}{} took {:.0}s{}",
52 call.agent,
53 call.kind,
54 match call.effort.as_str() {
55 "" => String::new(),
56 effort => format!(" at {effort}"),
57 },
58 match (call.retry, call.fallback) {
59 (true, _) => " (retry)",
60 (_, true) => " (standing in)",
61 _ => "",
62 },
63 call.seconds,
64 match call.tokens {
65 Some(tokens) => format!(", {tokens} tokens"),
66 None => String::new(),
67 }
68 );
69 SPENT
70 .lock()
71 .unwrap_or_else(std::sync::PoisonError::into_inner)
72 .push(call);
73}
74
75pub fn taken() -> Vec<Spent> {
77 SPENT
78 .lock()
79 .unwrap_or_else(std::sync::PoisonError::into_inner)
80 .clone()
81}
82
83#[cfg(test)]
84pub fn forget() {
85 SPENT
86 .lock()
87 .unwrap_or_else(std::sync::PoisonError::into_inner)
88 .clear();
89}
90
91pub fn summary(calls: &[Spent]) -> Option<String> {
93 if calls.is_empty() {
94 return None;
95 }
96 let mut agents: Vec<&str> = calls.iter().map(|c| c.agent.as_str()).collect();
97 agents.sort_unstable();
98 agents.dedup();
99
100 let mut lines = Vec::new();
101 for agent in agents {
102 let theirs: Vec<&Spent> = calls.iter().filter(|c| c.agent == agent).collect();
103 let seconds: f64 = theirs.iter().map(|c| c.seconds).sum();
104 let mut line = format!(
105 " {agent}: {} call(s), {}",
106 theirs.len(),
107 clock(Duration::from_secs_f64(seconds))
108 );
109 let retries = theirs.iter().filter(|c| c.retry).count();
110 let stood_in = theirs.iter().filter(|c| c.fallback).count();
111 let failed = theirs.iter().filter(|c| !c.ok).count();
112 for (count, what) in [
113 (retries, "retry"),
114 (stood_in, "standing in"),
115 (failed, "failed"),
116 ] {
117 if count > 0 {
118 line.push_str(&format!(", {count} {what}"));
119 }
120 }
121 let tokens: u64 = theirs.iter().filter_map(|c| c.tokens).sum();
122 if tokens > 0 {
123 line.push_str(&format!(", {tokens} tokens"));
124 }
125 lines.push(line);
126 }
127 let total: f64 = calls.iter().map(|c| c.seconds).sum();
128 Some(format!(
129 "spent: {} agent call(s), {} of model time\n{}",
130 calls.len(),
131 clock(Duration::from_secs_f64(total)),
132 lines.join("\n")
133 ))
134}
135
136pub fn for_subject(calls: &[Spent], subject: i64) -> Option<String> {
138 let theirs: Vec<&Spent> = calls
139 .iter()
140 .filter(|c| c.subject == Some(subject))
141 .collect();
142 if theirs.is_empty() {
143 return None;
144 }
145 let seconds: f64 = theirs.iter().map(|c| c.seconds).sum();
146 Some(format!(
147 "{} call(s), {}",
148 theirs.len(),
149 clock(Duration::from_secs_f64(seconds))
150 ))
151}
152
153fn clock(taken: Duration) -> String {
155 let seconds = taken.as_secs();
156 if seconds < 90 {
157 return format!("{seconds}s");
158 }
159 let minutes = seconds / 60;
160 if minutes < 90 {
161 return format!("{minutes}m{:02}s", seconds % 60);
162 }
163 format!("{}h{:02}m", minutes / 60, minutes % 60)
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn call(agent: &str, kind: &str, seconds: f64) -> Spent {
171 Spent {
172 agent: agent.into(),
173 kind: kind.into(),
174 effort: "high".into(),
175 subject: Some(42),
176 seconds,
177 retry: false,
178 fallback: false,
179 tokens: None,
180 ok: true,
181 }
182 }
183
184 #[test]
187 fn the_summary_says_what_each_agent_was_asked_and_how_long_it_took() {
188 let mut calls = vec![
189 call("claude", "triage", 30.0),
190 call("codex", "triage", 45.0),
191 call("claude", "implement", 120.0),
192 ];
193 calls[2].retry = true;
194 calls[2].tokens = Some(12_000);
195
196 let text = summary(&calls).expect("something was spent");
197 assert!(text.contains("3 agent call(s)"), "{text}");
198 assert!(text.contains("claude: 2 call(s)"), "{text}");
199 assert!(text.contains("codex: 1 call(s)"), "{text}");
200 assert!(text.contains("1 retry"), "{text}");
201 assert!(text.contains("12000 tokens"), "{text}");
202 assert_eq!(None, summary(&[]), "a run that asked nothing says nothing");
203 }
204
205 #[test]
206 fn one_issue_is_accounted_for_on_its_own() {
207 let mut other = call("codex", "review_1", 60.0);
208 other.subject = Some(43);
209 let calls = vec![call("claude", "implement", 30.0), other];
210 assert_eq!(Some("1 call(s), 30s".to_string()), for_subject(&calls, 42));
211 assert_eq!(None, for_subject(&calls, 99));
212 }
213
214 #[test]
216 fn wall_time_reads_as_time() {
217 assert_eq!("45s", clock(Duration::from_secs(45)));
218 assert_eq!("2m30s", clock(Duration::from_secs(150)));
219 assert_eq!("2h05m", clock(Duration::from_secs(7_500)));
220 }
221}