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
83pub fn drain() -> Vec<Spent> {
86 std::mem::take(
87 &mut *SPENT
88 .lock()
89 .unwrap_or_else(std::sync::PoisonError::into_inner),
90 )
91}
92
93#[cfg(test)]
94pub fn forget() {
95 SPENT
96 .lock()
97 .unwrap_or_else(std::sync::PoisonError::into_inner)
98 .clear();
99}
100
101pub fn summary(calls: &[Spent]) -> Option<String> {
103 if calls.is_empty() {
104 return None;
105 }
106 let mut agents: Vec<&str> = calls.iter().map(|c| c.agent.as_str()).collect();
107 agents.sort_unstable();
108 agents.dedup();
109
110 let mut lines = Vec::new();
111 for agent in agents {
112 let theirs: Vec<&Spent> = calls.iter().filter(|c| c.agent == agent).collect();
113 let seconds: f64 = theirs.iter().map(|c| c.seconds).sum();
114 let mut line = format!(
115 " {agent}: {} call(s), {}",
116 theirs.len(),
117 clock(Duration::from_secs_f64(seconds))
118 );
119 let retries = theirs.iter().filter(|c| c.retry).count();
120 let stood_in = theirs.iter().filter(|c| c.fallback).count();
121 let failed = theirs.iter().filter(|c| !c.ok).count();
122 for (count, what) in [
123 (retries, "retry"),
124 (stood_in, "standing in"),
125 (failed, "failed"),
126 ] {
127 if count > 0 {
128 line.push_str(&format!(", {count} {what}"));
129 }
130 }
131 let tokens: u64 = theirs.iter().filter_map(|c| c.tokens).sum();
132 if tokens > 0 {
133 line.push_str(&format!(", {tokens} tokens"));
134 }
135 lines.push(line);
136 }
137 let total: f64 = calls.iter().map(|c| c.seconds).sum();
138 Some(format!(
139 "spent: {} agent call(s), {} of model time\n{}",
140 calls.len(),
141 clock(Duration::from_secs_f64(total)),
142 lines.join("\n")
143 ))
144}
145
146pub fn for_subject(calls: &[Spent], subject: i64) -> Option<String> {
148 let theirs: Vec<&Spent> = calls
149 .iter()
150 .filter(|c| c.subject == Some(subject))
151 .collect();
152 if theirs.is_empty() {
153 return None;
154 }
155 let seconds: f64 = theirs.iter().map(|c| c.seconds).sum();
156 Some(format!(
157 "{} call(s), {}",
158 theirs.len(),
159 clock(Duration::from_secs_f64(seconds))
160 ))
161}
162
163pub(crate) fn clock(taken: Duration) -> String {
165 let seconds = taken.as_secs();
166 if seconds < 90 {
167 return format!("{seconds}s");
168 }
169 let minutes = seconds / 60;
170 if minutes < 90 {
171 return format!("{minutes}m{:02}s", seconds % 60);
172 }
173 format!("{}h{:02}m", minutes / 60, minutes % 60)
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn call(agent: &str, kind: &str, seconds: f64) -> Spent {
181 Spent {
182 agent: agent.into(),
183 kind: kind.into(),
184 effort: "high".into(),
185 subject: Some(42),
186 seconds,
187 retry: false,
188 fallback: false,
189 tokens: None,
190 ok: true,
191 }
192 }
193
194 #[test]
197 fn the_summary_says_what_each_agent_was_asked_and_how_long_it_took() {
198 let mut calls = vec![
199 call("claude", "triage", 30.0),
200 call("codex", "triage", 45.0),
201 call("claude", "implement", 120.0),
202 ];
203 calls[2].retry = true;
204 calls[2].tokens = Some(12_000);
205
206 let text = summary(&calls).expect("something was spent");
207 assert!(text.contains("3 agent call(s)"), "{text}");
208 assert!(text.contains("claude: 2 call(s)"), "{text}");
209 assert!(text.contains("codex: 1 call(s)"), "{text}");
210 assert!(text.contains("1 retry"), "{text}");
211 assert!(text.contains("12000 tokens"), "{text}");
212 assert_eq!(None, summary(&[]), "a run that asked nothing says nothing");
213 }
214
215 #[test]
216 fn one_issue_is_accounted_for_on_its_own() {
217 let mut other = call("codex", "review_1", 60.0);
218 other.subject = Some(43);
219 let calls = vec![call("claude", "implement", 30.0), other];
220 assert_eq!(Some("1 call(s), 30s".to_string()), for_subject(&calls, 42));
221 assert_eq!(None, for_subject(&calls, 99));
222 }
223
224 #[test]
226 fn wall_time_reads_as_time() {
227 assert_eq!("45s", clock(Duration::from_secs(45)));
228 assert_eq!("2m30s", clock(Duration::from_secs(150)));
229 assert_eq!("2h05m", clock(Duration::from_secs(7_500)));
230 }
231}