Skip to main content

spar/
spend.rs

1//! What a run spent.
2//!
3//! A run makes many agent calls at scheduled effort, and the terminal report
4//! said nothing about them: no count per agent, no wall time, no retries or
5//! hand-overs. `max_rounds`, `absorb_new_issues`, `max_followups` and the
6//! effort schedule are all documented in terms of what they cost, and nothing
7//! in the output let anybody see whether changing one did what they hoped.
8//!
9//! Ambient on purpose. Threading a counter through every call site would put
10//! accounting in the signature of everything that asks a model a question, and
11//! the one thing this must not do is change what the loop decides.
12
13use std::sync::Mutex;
14use std::time::Duration;
15
16use serde::{Deserialize, Serialize};
17
18/// One call to one agent.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Spent {
21    pub agent: String,
22    /// The kind of call, as the effort schedule names it: `triage`,
23    /// `review_1`, `respond`, and the rest.
24    pub kind: String,
25    /// What it was asked for, empty when the CLI's own default was used.
26    #[serde(default, skip_serializing_if = "String::is_empty")]
27    pub effort: String,
28    /// The issue or pull request this call was about, when it was about one.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub subject: Option<i64>,
31    pub seconds: f64,
32    /// The same agent, asked again after an unusable answer.
33    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
34    pub retry: bool,
35    /// A stand in answering for an agent that could not.
36    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
37    pub fallback: bool,
38    /// Where the CLI's event stream reports it. Blank where it does not, which
39    /// is most of them.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub tokens: Option<u64>,
42    /// Whether the call produced a usable answer.
43    pub ok: bool,
44}
45
46static SPENT: Mutex<Vec<Spent>> = Mutex::new(Vec::new());
47
48/// Record one call, and say so as it happens.
49pub 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
75/// Everything recorded so far.
76pub fn taken() -> Vec<Spent> {
77    SPENT
78        .lock()
79        .unwrap_or_else(std::sync::PoisonError::into_inner)
80        .clone()
81}
82
83/// Everything recorded so far, and forget it. For a process that reports
84/// more than once, so a dispatch's calls are written down exactly once.
85pub 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
101/// One line per agent, and one for the run, or nothing when nothing was asked.
102pub 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
146/// What one issue cost, for the line under its result.
147pub 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
163/// Wall time as somebody reads it, rather than as a float.
164pub(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    /// Tuning was blind: the documented reason `absorb_new_issues` is off is
195    /// that it "multiplies what a run costs", and nothing showed the multiplier.
196    #[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    /// Seconds are what the clock gives and minutes are what a person reads.
225    #[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}