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 story's checkpoint the task's acceptance may be moved to.
41    pub defer_to: Option<String>,
42    /// The full ref name.
43    pub reference: String,
44    /// The commit it points at.
45    pub commit: String,
46}
47
48pub(crate) const IDENTITY: [&str; 6] = [
49    "-c",
50    "user.name=speccoder",
51    "-c",
52    "user.email=speccoder@users.noreply.invalid",
53    "-c",
54    "commit.gpgsign=false",
55];
56
57/// What an attempt ended with.
58#[derive(Debug, Clone, Copy)]
59pub struct Record<'a> {
60    /// The agentplane run, when there was one (it names the ref).
61    pub run: Option<&'a str>,
62    /// `blocked`, `verification_failed`, …
63    pub outcome: &'a str,
64    /// Why.
65    pub reason: &'a str,
66    /// The call awaiting approval, if the attempt stopped for one.
67    pub pending: Option<&'a serde_json::Value>,
68    /// Model tokens the attempt used (`cost`).
69    pub usage: Option<&'a serde_json::Value>,
70    /// The story's checkpoint, when a person may move this task's
71    /// acceptance there (its tests could not be met mid-story).
72    pub defer_to: Option<&'a str>,
73}
74
75/// The `Usage:` trailer speccoder writes on its commits, or nothing.
76#[must_use]
77pub fn usage_trailer(usage: Option<&serde_json::Value>) -> String {
78    let Some(u) = usage.filter(|u| u.is_object()) else {
79        return String::new();
80    };
81    let n = |k: &str| u[k].as_u64().unwrap_or(0);
82    // An external agent that reports its own cost (Claude Code) keeps it.
83    let usd = u["cost_usd"]
84        .as_f64()
85        .map(|c| format!(" usd={c}"))
86        .unwrap_or_default();
87    format!(
88        "Usage: input={} output={} cache_read={} cache_write={}{usd}\n",
89        n("input_tokens"),
90        n("output_tokens"),
91        n("cache_read_tokens"),
92        n("cache_write_tokens")
93    )
94}
95
96/// Reads a `Usage:` trailer value back into a usage object.
97#[must_use]
98pub fn parse_usage(value: &str) -> Option<serde_json::Value> {
99    let mut out = serde_json::Map::new();
100    for part in value.split_whitespace() {
101        let (k, v) = part.split_once('=')?;
102        let key = match k {
103            "input" => "input_tokens",
104            "output" => "output_tokens",
105            "cache_read" => "cache_read_tokens",
106            "cache_write" => "cache_write_tokens",
107            "usd" => {
108                out.insert(
109                    "cost_usd".to_owned(),
110                    serde_json::json!(v.parse::<f64>().ok()?),
111                );
112                continue;
113            }
114            _ => continue,
115        };
116        out.insert(key.to_owned(), serde_json::json!(v.parse::<u64>().ok()?));
117    }
118    (!out.is_empty()).then_some(serde_json::Value::Object(out))
119}
120
121/// Saves the worktree at `dir` as an attempt of `feature/task`.
122pub fn save(
123    dir: &Path,
124    tmp: &Path,
125    feature: &str,
126    task: &str,
127    record: &Record<'_>,
128) -> Result<Saved, GitError> {
129    let Record {
130        run,
131        outcome,
132        reason,
133        pending,
134        usage,
135        defer_to,
136    } = *record;
137    let git = Git::at(dir);
138    let tree = worktree::checkpoint(dir, tmp)?;
139    let head = git.line(&["rev-parse", "--verify", "HEAD^{commit}"])?;
140    // A file whose change holds a value copied from the person's secret
141    // files is left out: a saved attempt is a ref anyone with the
142    // repository sees.
143    let (tree, left_out) = without_secrets(&git, tmp, &head, tree)?;
144    let reason = if left_out.is_empty() {
145        reason.to_owned()
146    } else {
147        format!(
148            "{reason} ({} left out: it held a value copied from your checkout)",
149            left_out.join(", ")
150        )
151    };
152    let reason = reason.as_str();
153    let message = format!(
154        "speccoder attempt: {task} {outcome}\n\n{reason}\n\nSpec-Task: {feature}/{task}\nOutcome: {outcome}\n{}{}{}{}",
155        run.map(|r| format!("Run: {r}\n")).unwrap_or_default(),
156        defer_to
157            .filter(|t| t
158                .chars()
159                .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-'))
160            .map(|t| format!("Defer-To: {t}\n"))
161            .unwrap_or_default(),
162        usage_trailer(usage),
163        // Compact JSON is one line: a trailer value.
164        pending
165            .map(|p| format!("Pending: {p}\n"))
166            .unwrap_or_default()
167    );
168    let mut args = IDENTITY.to_vec();
169    args.extend(["commit-tree", &tree.0, "-p", &head, "-m", &message]);
170    let commit = git.line(&args)?;
171    let name = run
172        .filter(|r| {
173            r.chars()
174                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
175        })
176        .map_or_else(
177            || format!("{outcome}-{}", commit.get(..12).unwrap_or(&commit)),
178            str::to_owned,
179        );
180    let reference = format!("{PREFIX}/{feature}/{task}/{name}");
181    git.run(&["update-ref", &reference, &commit])?;
182    Ok(Saved {
183        task: task.to_owned(),
184        outcome: outcome.to_owned(),
185        reason: reason.to_owned(),
186        run_id: run.map(str::to_owned),
187        pending: pending.cloned(),
188        usage: usage.cloned(),
189        defer_to: defer_to.map(str::to_owned),
190        reference,
191        commit,
192    })
193}
194
195/// `tree` without each file whose change from `head` adds a value
196/// [`crate::secrets`] masks, and those files.
197fn without_secrets(
198    git: &Git,
199    tmp: &Path,
200    head: &str,
201    tree: Checkpoint,
202) -> Result<(Checkpoint, Vec<String>), GitError> {
203    let changed = crate::git::nul_fields(&git.run(&[
204        "diff",
205        "--name-only",
206        "-z",
207        "--no-renames",
208        head,
209        &tree.0,
210    ])?);
211    let held: Vec<String> = changed
212        .into_iter()
213        .filter(|f| {
214            git.run(&["diff", "-U0", "--no-color", head, &tree.0, "--", f])
215                .is_ok_and(|p| crate::include::added_secret(&String::from_utf8_lossy(&p)).is_some())
216        })
217        .collect();
218    if held.is_empty() {
219        return Ok((tree, held));
220    }
221    let index = tmp.join(format!("attempt-index-{}", std::process::id()));
222    let _ = std::fs::create_dir_all(tmp);
223    git.run_with_index(&index, &["read-tree", &tree.0], None)?;
224    let mut rm = vec!["update-index", "--force-remove", "--"];
225    rm.extend(held.iter().map(String::as_str));
226    git.run_with_index(&index, &rm, None)?;
227    let out = git.run_with_index(&index, &["write-tree"], None);
228    let _ = std::fs::remove_file(&index);
229    let kept = String::from_utf8_lossy(&out?).trim().to_owned();
230    Ok((Checkpoint(kept), held))
231}
232
233/// Makes the worktree at `dir` equal to its HEAD commit's tree.
234pub fn restore_head(dir: &Path, tmp: &Path) -> Result<(), GitError> {
235    let tree = Git::at(dir).line(&["rev-parse", "HEAD^{tree}"])?;
236    worktree::restore(dir, tmp, &Checkpoint(tree)).map(|_| ())
237}
238
239/// The saved attempts of `feature`, newest first.
240pub fn list(repo: &Path, feature: &str) -> Vec<Saved> {
241    let format = format!(
242        "--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}%(trailers:key=Defer-To,valueonly){FS}%(contents:body){RS}"
243    );
244    let out = Git::at(repo)
245        .run(&[
246            "for-each-ref",
247            "--sort=-committerdate",
248            &format,
249            &format!("{PREFIX}/{feature}/"),
250        ])
251        .map(|b| String::from_utf8_lossy(&b).into_owned())
252        .unwrap_or_default();
253    out.split(RS)
254        .filter_map(|record| {
255            let f: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
256            let [
257                reference,
258                commit,
259                task,
260                outcome,
261                run,
262                pending,
263                usage,
264                defer_to,
265                body,
266            ] = f[..]
267            else {
268                return None;
269            };
270            let task = task.trim().strip_prefix(&format!("{feature}/"))?.to_owned();
271            // The reason is the body before the trailer block.
272            let reason = body
273                .split("\n\nSpec-Task:")
274                .next()
275                .unwrap_or_default()
276                .trim()
277                .to_owned();
278            let run = run.trim();
279            Some(Saved {
280                task,
281                outcome: outcome.trim().to_owned(),
282                reason,
283                run_id: (!run.is_empty()).then(|| run.to_owned()),
284                pending: serde_json::from_str(pending.trim()).ok(),
285                usage: parse_usage(usage.trim()),
286                defer_to: Some(defer_to.trim().to_owned()).filter(|t| !t.is_empty()),
287                reference: reference.to_owned(),
288                commit: commit.to_owned(),
289            })
290        })
291        .collect()
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn a_file_holding_a_copied_value_is_left_out_of_a_saved_attempt() {
300        let dir = tempfile::tempdir().unwrap();
301        let r = dir.path();
302        let git = |args: &[&str]| {
303            assert!(
304                std::process::Command::new("git")
305                    .args([
306                        "-c",
307                        "user.name=t",
308                        "-c",
309                        "user.email=t@e",
310                        "-c",
311                        "commit.gpgsign=false"
312                    ])
313                    .args(args)
314                    .current_dir(r)
315                    .status()
316                    .unwrap()
317                    .success()
318            );
319        };
320        std::fs::write(r.join("a.txt"), "a\n").unwrap();
321        git(&["init", "-q"]);
322        git(&["add", "-A"]);
323        git(&["commit", "-qm", "init"]);
324        crate::secrets::register("SNAP_TOKEN", "attempt-snap-Vb3 value");
325        std::fs::write(r.join("a.txt"), "a\nb\n").unwrap();
326        std::fs::write(r.join("snap.txt"), "token: attempt-snap-Vb3 value\n").unwrap();
327        let tmp = tempfile::tempdir().unwrap();
328        let saved = save(
329            r,
330            tmp.path(),
331            "001-x",
332            "T001",
333            &Record {
334                run: None,
335                outcome: "failed",
336                reason: "red",
337                pending: None,
338                usage: None,
339                defer_to: None,
340            },
341        )
342        .unwrap();
343        let g = Git::at(r);
344        let files = g
345            .run(&["ls-tree", "-r", "--name-only", &saved.commit])
346            .unwrap();
347        assert_eq!(String::from_utf8_lossy(&files), "a.txt\n");
348        assert!(
349            saved.reason.contains("snap.txt left out"),
350            "{}",
351            saved.reason
352        );
353    }
354
355    #[test]
356    fn usage_trailers_round_trip_with_a_reported_cost() {
357        let u = serde_json::json!({ "input_tokens": 100, "output_tokens": 5, "cache_read_tokens": 90, "cache_write_tokens": 3, "cost_usd": 0.0125 });
358        let t = usage_trailer(Some(&u));
359        assert_eq!(
360            t,
361            "Usage: input=100 output=5 cache_read=90 cache_write=3 usd=0.0125\n"
362        );
363        let back = parse_usage(t.trim().trim_start_matches("Usage: ")).unwrap_or_default();
364        assert_eq!(back, u);
365        let native = parse_usage("input=1 output=2 cache_read=0 cache_write=0").unwrap_or_default();
366        assert!(native.get("cost_usd").is_none());
367    }
368}