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