Skip to main content

speccoder_runtime/
attempts.rs

1//! Durable attempt outcomes.
2//!
3//! A passed task is a commit on `speccoder/<feature>`. Every other outcome
4//! — a failed verdict, a stop, an undemonstrated gap, an interrupted run —
5//! is saved as a commit object whose tree is the worktree as the attempt
6//! left it, parented on the branch tip, under
7//! `refs/speccoder/attempts/<feature>/<task>/<run>`, with its outcome in
8//! trailers. The worktree then goes back to where the attempt started, so
9//! the next attempt never builds on (or freezes tests from) unfinished work,
10//! and nothing is lost: `git diff speccoder/<feature> <ref>` shows it.
11//! Git is the durable record; the journal can be replaced across upgrades.
12
13use std::path::Path;
14
15use crate::git::{Git, GitError};
16use crate::worktree::{self, Checkpoint};
17
18/// Where attempt refs live.
19pub const PREFIX: &str = "refs/speccoder/attempts";
20
21/// Field and record separators for `git for-each-ref` output.
22const FS: char = '\u{1f}';
23const RS: char = '\u{1e}';
24
25/// An attempt saved under [`PREFIX`].
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Saved {
28    /// Task ID.
29    pub task: String,
30    /// `blocked`, `verification_failed`, `not_demonstrated`, `interrupted`, …
31    pub outcome: String,
32    /// Why, in one or a few lines.
33    pub reason: String,
34    /// The agentplane run, when there was one.
35    pub run_id: Option<String>,
36    /// The call that awaited approval (`{tool, arguments, reason}`), if any.
37    pub pending: Option<serde_json::Value>,
38    /// Model tokens the attempt used.
39    pub usage: Option<serde_json::Value>,
40    /// The full ref name.
41    pub reference: String,
42    /// The commit it points at.
43    pub commit: String,
44}
45
46pub(crate) const IDENTITY: [&str; 6] = [
47    "-c",
48    "user.name=speccoder",
49    "-c",
50    "user.email=speccoder@users.noreply.invalid",
51    "-c",
52    "commit.gpgsign=false",
53];
54
55/// What an attempt ended with.
56#[derive(Debug, Clone, Copy)]
57pub struct Record<'a> {
58    /// The agentplane run, when there was one (it names the ref).
59    pub run: Option<&'a str>,
60    /// `blocked`, `verification_failed`, …
61    pub outcome: &'a str,
62    /// Why.
63    pub reason: &'a str,
64    /// The call awaiting approval, if the attempt stopped for one.
65    pub pending: Option<&'a serde_json::Value>,
66    /// Model tokens the attempt used (`cost`).
67    pub usage: Option<&'a serde_json::Value>,
68}
69
70/// The `Usage:` trailer speccoder writes on its commits, or nothing.
71#[must_use]
72pub fn usage_trailer(usage: Option<&serde_json::Value>) -> String {
73    let Some(u) = usage.filter(|u| u.is_object()) else {
74        return String::new();
75    };
76    let n = |k: &str| u[k].as_u64().unwrap_or(0);
77    // An external agent that reports its own cost (Claude Code) keeps it.
78    let usd = u["cost_usd"]
79        .as_f64()
80        .map(|c| format!(" usd={c}"))
81        .unwrap_or_default();
82    format!(
83        "Usage: input={} output={} cache_read={} cache_write={}{usd}\n",
84        n("input_tokens"),
85        n("output_tokens"),
86        n("cache_read_tokens"),
87        n("cache_write_tokens")
88    )
89}
90
91/// Reads a `Usage:` trailer value back into a usage object.
92#[must_use]
93pub fn parse_usage(value: &str) -> Option<serde_json::Value> {
94    let mut out = serde_json::Map::new();
95    for part in value.split_whitespace() {
96        let (k, v) = part.split_once('=')?;
97        let key = match k {
98            "input" => "input_tokens",
99            "output" => "output_tokens",
100            "cache_read" => "cache_read_tokens",
101            "cache_write" => "cache_write_tokens",
102            "usd" => {
103                out.insert(
104                    "cost_usd".to_owned(),
105                    serde_json::json!(v.parse::<f64>().ok()?),
106                );
107                continue;
108            }
109            _ => continue,
110        };
111        out.insert(key.to_owned(), serde_json::json!(v.parse::<u64>().ok()?));
112    }
113    (!out.is_empty()).then_some(serde_json::Value::Object(out))
114}
115
116/// Saves the worktree at `dir` as an attempt of `feature/task`.
117pub fn save(
118    dir: &Path,
119    tmp: &Path,
120    feature: &str,
121    task: &str,
122    record: &Record<'_>,
123) -> Result<Saved, GitError> {
124    let Record {
125        run,
126        outcome,
127        reason,
128        pending,
129        usage,
130    } = *record;
131    let git = Git::at(dir);
132    let tree = worktree::checkpoint(dir, tmp)?;
133    let head = git.line(&["rev-parse", "--verify", "HEAD^{commit}"])?;
134    let message = format!(
135        "speccoder attempt: {task} {outcome}\n\n{reason}\n\nSpec-Task: {feature}/{task}\nOutcome: {outcome}\n{}{}{}",
136        run.map(|r| format!("Run: {r}\n")).unwrap_or_default(),
137        usage_trailer(usage),
138        // Compact JSON is one line: a trailer value.
139        pending
140            .map(|p| format!("Pending: {p}\n"))
141            .unwrap_or_default()
142    );
143    let mut args = IDENTITY.to_vec();
144    args.extend(["commit-tree", &tree.0, "-p", &head, "-m", &message]);
145    let commit = git.line(&args)?;
146    let name = run
147        .filter(|r| {
148            r.chars()
149                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
150        })
151        .map_or_else(
152            || format!("{outcome}-{}", commit.get(..12).unwrap_or(&commit)),
153            str::to_owned,
154        );
155    let reference = format!("{PREFIX}/{feature}/{task}/{name}");
156    git.run(&["update-ref", &reference, &commit])?;
157    Ok(Saved {
158        task: task.to_owned(),
159        outcome: outcome.to_owned(),
160        reason: reason.to_owned(),
161        run_id: run.map(str::to_owned),
162        pending: pending.cloned(),
163        usage: usage.cloned(),
164        reference,
165        commit,
166    })
167}
168
169/// Makes the worktree at `dir` equal to its HEAD commit's tree.
170pub fn restore_head(dir: &Path, tmp: &Path) -> Result<(), GitError> {
171    let tree = Git::at(dir).line(&["rev-parse", "HEAD^{tree}"])?;
172    worktree::restore(dir, tmp, &Checkpoint(tree)).map(|_| ())
173}
174
175/// The saved attempts of `feature`, newest first.
176pub fn list(repo: &Path, feature: &str) -> Vec<Saved> {
177    let format = format!(
178        "--format=%(refname){FS}%(objectname){FS}%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Outcome,valueonly){FS}%(trailers:key=Run,valueonly){FS}%(trailers:key=Pending,valueonly){FS}%(trailers:key=Usage,valueonly){FS}%(contents:body){RS}"
179    );
180    let out = Git::at(repo)
181        .run(&[
182            "for-each-ref",
183            "--sort=-committerdate",
184            &format,
185            &format!("{PREFIX}/{feature}/"),
186        ])
187        .map(|b| String::from_utf8_lossy(&b).into_owned())
188        .unwrap_or_default();
189    out.split(RS)
190        .filter_map(|record| {
191            let f: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
192            let [reference, commit, task, outcome, run, pending, usage, body] = f[..] else {
193                return None;
194            };
195            let task = task.trim().strip_prefix(&format!("{feature}/"))?.to_owned();
196            // The reason is the body before the trailer block.
197            let reason = body
198                .split("\n\nSpec-Task:")
199                .next()
200                .unwrap_or_default()
201                .trim()
202                .to_owned();
203            let run = run.trim();
204            Some(Saved {
205                task,
206                outcome: outcome.trim().to_owned(),
207                reason,
208                run_id: (!run.is_empty()).then(|| run.to_owned()),
209                pending: serde_json::from_str(pending.trim()).ok(),
210                usage: parse_usage(usage.trim()),
211                reference: reference.to_owned(),
212                commit: commit.to_owned(),
213            })
214        })
215        .collect()
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn usage_trailers_round_trip_with_a_reported_cost() {
224        let u = serde_json::json!({ "input_tokens": 100, "output_tokens": 5, "cache_read_tokens": 90, "cache_write_tokens": 3, "cost_usd": 0.0125 });
225        let t = usage_trailer(Some(&u));
226        assert_eq!(
227            t,
228            "Usage: input=100 output=5 cache_read=90 cache_write=3 usd=0.0125\n"
229        );
230        let back = parse_usage(t.trim().trim_start_matches("Usage: ")).unwrap_or_default();
231        assert_eq!(back, u);
232        let native = parse_usage("input=1 output=2 cache_read=0 cache_write=0").unwrap_or_default();
233        assert!(native.get("cost_usd").is_none());
234    }
235}