Skip to main content

speccoder_runtime/
resume.rs

1//! Resuming an attempt a killed process left behind.
2//!
3//! Before an attempt, a marker records its admission key, the checkpoint it
4//! started from and the acceptance tests it is judged by. A run that ends in
5//! any ordinary way removes it ([`Guard`]); a killed process cannot, so a
6//! marker on disk means an attempt was interrupted. The next run then keeps
7//! the worktree as the attempt left it and admits the attempt under the same
8//! key: agentplane finds the interrupted run and resumes it from its journal
9//! — model calls and tool calls that finished are read back, not repeated —
10//! and a run that had already finished is read back whole and judged again
11//! (a task that passed just before the kill is ticked now).
12
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16use sha2::{Digest as _, Sha256};
17
18/// An attempt in progress.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Marker {
21    /// The agentplane admission key.
22    pub key: String,
23    /// The checkpoint the attempt started from.
24    pub before: String,
25    /// The worktree's HEAD then; a different HEAD means the marker is stale.
26    pub head: String,
27    /// Which attempt of the task run.
28    pub attempt: u32,
29    /// Which best-of-n sample (0-based).
30    pub sample: usize,
31    /// The acceptance tests prepared for the task run.
32    pub acceptance: Option<serde_json::Value>,
33    /// The story oracle's run before the attempt (a task of a story): what
34    /// a resumed attempt is judged against, never its own changed tree.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub story_before: Option<serde_json::Value>,
37    /// The run the attempt belongs to: an invocation resuming it adopts it.
38    #[serde(default)]
39    pub run_id: Option<String>,
40    /// The run's prompts when the attempt started.
41    #[serde(default)]
42    pub asked: u32,
43    /// The run's grants when it started.
44    #[serde(default)]
45    pub run_grants: Vec<crate::tools::policy::Object>,
46}
47
48/// Where the marker of `feature/task` lives.
49#[must_use]
50pub fn path(plane_dir: &Path, feature: &str, task: &str) -> PathBuf {
51    plane_dir
52        .join("resume")
53        .join(feature)
54        .join(format!("{task}.json"))
55}
56
57/// The marker, if an attempt was interrupted.
58#[must_use]
59pub fn load(path: &Path) -> Option<Marker> {
60    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
61}
62
63/// Records `marker` (written whole, then renamed: never half a marker).
64pub fn save(path: &Path, marker: &Marker) -> std::io::Result<()> {
65    if let Some(dir) = path.parent() {
66        std::fs::create_dir_all(dir)?;
67    }
68    let tmp = path.with_extension("json.tmp");
69    std::fs::write(
70        &tmp,
71        serde_json::to_vec(marker).map_err(std::io::Error::other)?,
72    )?;
73    std::fs::rename(tmp, path)
74}
75
76/// A fresh admission key: unique per attempt, so an ordinary rerun never
77/// meets an old attempt's run.
78#[must_use]
79pub fn new_key(feature: &str, task: &str) -> String {
80    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
81    let nanos = std::time::SystemTime::now()
82        .duration_since(std::time::UNIX_EPOCH)
83        .map_or(0, |d| d.as_nanos());
84    let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
85    let digest = Sha256::digest(format!(
86        "{feature}/{task}/{nanos}/{}/{n}",
87        std::process::id()
88    ));
89    let hex: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
90    format!("attempt-{hex}")
91}
92
93/// Removes the marker when a task run that started an attempt ends in any
94/// ordinary way. A run refused before its attempt starts (a session check,
95/// a worktree held by a waiting call) leaves an interrupted or waiting
96/// attempt's marker alone: that attempt still resumes.
97#[derive(Debug)]
98pub struct Guard {
99    path: PathBuf,
100    armed: std::cell::Cell<bool>,
101}
102
103impl Guard {
104    /// A guard for the marker at `path`, not yet armed.
105    #[must_use]
106    pub fn new(path: PathBuf) -> Self {
107        Self {
108            path,
109            armed: std::cell::Cell::new(false),
110        }
111    }
112
113    /// An attempt is starting: the marker goes when the run ends.
114    pub fn arm(&self) {
115        self.armed.set(true);
116    }
117
118    /// Leaves the marker in place: the attempt waits for a person and the
119    /// next run resumes it.
120    pub fn keep(self) {
121        self.armed.set(false);
122    }
123}
124
125impl Drop for Guard {
126    fn drop(&mut self) {
127        if self.armed.get() {
128            let _ = std::fs::remove_file(&self.path);
129        }
130    }
131}