Skip to main content

yoagent_state/
git_store.rs

1//! Git-backed event store satisfying the GASP store contract (the rules
2//! restated inline here are authoritative for this crate; the full spec lives
3//! in the `gasp` repo's SPEC.md):
4//!
5//! 1. **Durable append, boundary commit.** Every `append` opens the log in
6//!    append mode, writes, flushes, and fsyncs (plus a parent-directory fsync
7//!    when the log file is first created), so a crash loses at most the batch
8//!    being written. A crash mid-write can leave a torn final line; `scan`
9//!    reports it with a recovery hint rather than reading past it.
10//!    `commit_run` makes the one closing git commit per run boundary.
11//! 2. **The lease lives inside the append path.** `append` takes or renews the
12//!    cross-process lease at `.agent/lease` (local-only, gitignored) before
13//!    writing; acquisition races on the lease file are decided by an atomic
14//!    exclusive create. `worker_id` MUST be unique per worker — two processes
15//!    sharing a worker id are treated as the same writer. Residual caveat:
16//!    stealing an *expired* lease has a narrow three-process race window, and
17//!    file-based leases are advisory on network filesystems.
18
19use crate::store::scan_after_events;
20use crate::{Event, EventId, EventStore, GoalId, RunId, StateError};
21use async_trait::async_trait;
22use serde::{Deserialize, Serialize};
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::sync::Arc;
27use std::time::Duration;
28use tokio::io::AsyncWriteExt;
29
30pub const DEFAULT_EVENTS_PATH: &str = "state/events.jsonl";
31pub const LEASE_PATH: &str = ".agent/lease";
32const GITIGNORED: &[&str] = &[".agent/lease", ".agent/HEAD"];
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35struct Lease {
36    worker_id: String,
37    pid: u32,
38    expires_at_ms: i64,
39}
40
41impl Lease {
42    fn take(worker_id: &str, ttl: Duration) -> Self {
43        Self {
44            worker_id: worker_id.to_string(),
45            pid: std::process::id(),
46            expires_at_ms: crate::now_ms() + ttl.as_millis() as i64,
47        }
48    }
49
50    /// Absent file -> `Ok(None)`. Corrupt or unreadable file -> `Err`: a lease
51    /// we cannot read is NOT a lease we may steal.
52    fn read(path: &Path) -> Result<Option<Lease>, StateError> {
53        match std::fs::read_to_string(path) {
54            Ok(raw) => serde_json::from_str(&raw).map(Some).map_err(|err| {
55                StateError::Store(format!(
56                    "lease file {} is corrupt ({err}); inspect or remove it manually",
57                    path.display()
58                ))
59            }),
60            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
61            Err(err) => Err(StateError::Store(format!(
62                "cannot read lease {}: {err}; refusing to write without proof of exclusivity",
63                path.display()
64            ))),
65        }
66    }
67
68    fn is_held_by(&self, worker_id: &str) -> bool {
69        self.worker_id == worker_id
70    }
71
72    fn is_live(&self, now_ms: i64) -> bool {
73        self.expires_at_ms > now_ms
74    }
75}
76
77#[derive(Debug, Clone)]
78pub struct GitEventStore {
79    repo_root: PathBuf,
80    worker_id: String,
81    lease_ttl: Duration,
82    /// Serializes appends across tasks/clones in this process; the lease
83    /// serializes across processes.
84    append_lock: Arc<tokio::sync::Mutex<()>>,
85}
86
87impl GitEventStore {
88    /// Open a GASP agent repo. `repo_root` must be inside a git work tree;
89    /// `worker_id` names this writer in the lease and must be unique per
90    /// worker.
91    pub fn open(
92        repo_root: impl Into<PathBuf>,
93        worker_id: impl Into<String>,
94    ) -> Result<Self, StateError> {
95        let store = Self {
96            repo_root: repo_root.into(),
97            worker_id: worker_id.into(),
98            lease_ttl: Duration::from_secs(600),
99            append_lock: Arc::new(tokio::sync::Mutex::new(())),
100        };
101        if store.worker_id.trim().is_empty() {
102            return Err(StateError::Validation("worker_id must be non-empty".into()));
103        }
104        if !store.repo_root.join(".git").exists() {
105            return Err(StateError::Store(format!(
106                "{} is not a git repository (the GASP layout requires one)",
107                store.repo_root.display()
108            )));
109        }
110        std::fs::create_dir_all(store.events_path().parent().unwrap())?;
111        std::fs::create_dir_all(store.lease_path().parent().unwrap())?;
112        store.ensure_gitignore()?;
113        Ok(store)
114    }
115
116    pub fn with_lease_ttl(mut self, ttl: Duration) -> Self {
117        self.lease_ttl = ttl;
118        self
119    }
120
121    pub fn events_path(&self) -> PathBuf {
122        self.repo_root.join(DEFAULT_EVENTS_PATH)
123    }
124
125    fn lease_path(&self) -> PathBuf {
126        self.repo_root.join(LEASE_PATH)
127    }
128
129    /// The control plane is local-only: make sure the repo ignores it. Only a
130    /// missing .gitignore reads as empty — any other read error propagates so
131    /// a transient failure can never truncate user content.
132    fn ensure_gitignore(&self) -> Result<(), StateError> {
133        let path = self.repo_root.join(".gitignore");
134        let existing = match std::fs::read_to_string(&path) {
135            Ok(raw) => raw,
136            Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
137            Err(err) => return Err(err.into()),
138        };
139        let missing: Vec<&str> = GITIGNORED
140            .iter()
141            .copied()
142            .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
143            .collect();
144        if missing.is_empty() {
145            return Ok(());
146        }
147        let mut content = existing;
148        if !content.is_empty() && !content.ends_with('\n') {
149            content.push('\n');
150        }
151        for entry in missing {
152            content.push_str(entry);
153            content.push('\n');
154        }
155        let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
156        std::fs::write(&tmp, content)?;
157        std::fs::rename(&tmp, &path)?;
158        Ok(())
159    }
160
161    /// Take or renew the single-writer lease. Mutual exclusion is decided by
162    /// an atomic exclusive create of the lease file; renewal (same worker)
163    /// replaces it via tmp+rename. Private by design: the only call sites are
164    /// `append` and `commit_run` — keep it that way so the check can't be
165    /// skipped.
166    fn acquire_lease(&self) -> Result<(), StateError> {
167        let path = self.lease_path();
168        for _ in 0..3 {
169            let lease = Lease::take(&self.worker_id, self.lease_ttl);
170            let payload = serde_json::to_vec(&lease)?;
171            match std::fs::OpenOptions::new()
172                .write(true)
173                .create_new(true)
174                .open(&path)
175            {
176                Ok(mut file) => {
177                    file.write_all(&payload)?;
178                    file.sync_all()?;
179                    return Ok(());
180                }
181                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
182                    let Some(existing) = Lease::read(&path)? else {
183                        continue; // vanished between create and read; retry
184                    };
185                    if existing.is_held_by(&self.worker_id) {
186                        // Renewal: we own it; atomic replace.
187                        let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
188                        std::fs::write(&tmp, &payload)?;
189                        std::fs::rename(&tmp, &path)?;
190                        return Ok(());
191                    }
192                    if existing.is_live(crate::now_ms()) {
193                        return Err(StateError::Store(format!(
194                            "lease held by worker `{}` (pid {}) until {}",
195                            existing.worker_id, existing.pid, existing.expires_at_ms
196                        )));
197                    }
198                    // Expired lease of another worker: steal by atomically
199                    // renaming it away, then verify we didn't grab a lease
200                    // that was refreshed between our read and the rename.
201                    let steal = path.with_extension(format!("steal-{}", std::process::id()));
202                    match std::fs::rename(&path, &steal) {
203                        Ok(()) => {
204                            if let Some(stolen) = Lease::read(&steal)? {
205                                if stolen.is_live(crate::now_ms())
206                                    && !stolen.is_held_by(&self.worker_id)
207                                {
208                                    std::fs::rename(&steal, &path)?;
209                                    return Err(StateError::Store(format!(
210                                        "lease held by worker `{}` (pid {}) until {}",
211                                        stolen.worker_id, stolen.pid, stolen.expires_at_ms
212                                    )));
213                                }
214                            }
215                            let _ = std::fs::remove_file(&steal);
216                            continue; // lease slot is free; retry the create
217                        }
218                        Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
219                        Err(err) => return Err(err.into()),
220                    }
221                }
222                Err(err) => return Err(err.into()),
223            }
224        }
225        Err(StateError::Store(
226            "could not acquire lease after repeated contention; retry".into(),
227        ))
228    }
229
230    /// Release the lease if this worker holds it. A corrupt or unreadable
231    /// lease file is an error — silently leaving it in place would deadlock
232    /// every future `acquire_lease`.
233    pub fn release_lease(&self) -> Result<(), StateError> {
234        let path = self.lease_path();
235        match Lease::read(&path)? {
236            Some(lease) if lease.is_held_by(&self.worker_id) => {
237                std::fs::remove_file(&path)?;
238                Ok(())
239            }
240            _ => Ok(()),
241        }
242    }
243
244    fn git(&self, args: &[&str]) -> Result<String, StateError> {
245        let out = Command::new("git")
246            .arg("-C")
247            .arg(&self.repo_root)
248            .args(args)
249            .output()
250            .map_err(|err| StateError::Store(format!("git: {err}")))?;
251        if !out.status.success() {
252            return Err(StateError::Store(format!(
253                "git {args:?}: {}",
254                String::from_utf8_lossy(&out.stderr).trim()
255            )));
256        }
257        Ok(String::from_utf8_lossy(&out.stdout).to_string())
258    }
259
260    /// The run-boundary commit: stage the log plus any projection paths and
261    /// commit **only those paths** with the GASP trailers (content staged by
262    /// anyone else stays staged, untouched). Requires the lease. Returns the
263    /// commit sha, or `Ok(None)` when nothing in the requested paths changed —
264    /// unrelated dirty files in the worktree do not affect the outcome.
265    pub fn commit_run(
266        &self,
267        run_id: &RunId,
268        goal: &GoalId,
269        outcome: &str,
270        extra_paths: &[&str],
271    ) -> Result<Option<String>, StateError> {
272        for (name, value) in [
273            ("run_id", run_id.as_str()),
274            ("goal", goal.as_str()),
275            ("outcome", outcome),
276        ] {
277            if value.contains('\n') {
278                return Err(StateError::Validation(format!(
279                    "{name} must not contain newlines (would forge commit trailers)"
280                )));
281            }
282        }
283        self.acquire_lease()?;
284        if !self.events_path().exists() {
285            let tracked = self.git(&["ls-files", "--", DEFAULT_EVENTS_PATH])?;
286            if tracked.trim().is_empty() {
287                return Ok(None); // no events ever recorded: nothing to ship
288            }
289            return Err(StateError::Store(format!(
290                "{DEFAULT_EVENTS_PATH} is tracked but missing from the worktree — refusing to commit its deletion as a run boundary"
291            )));
292        }
293
294        let mut paths: Vec<&str> = vec![DEFAULT_EVENTS_PATH];
295        paths.extend(extra_paths);
296
297        let mut add_args = vec!["add", "--"];
298        add_args.extend(&paths);
299        self.git(&add_args)?;
300
301        let mut status_args = vec!["status", "--porcelain", "--"];
302        status_args.extend(&paths);
303        if self.git(&status_args)?.trim().is_empty() {
304            return Ok(None);
305        }
306
307        let message = format!(
308            "run {run_id}: {outcome}\n\nRun-Id: {run_id}\nGoal: {goal}\nOutcome: {outcome}"
309        );
310        let mut commit_args = vec!["commit", "-q", "-m", &message, "--"];
311        commit_args.extend(&paths);
312        self.git(&commit_args)?;
313        Ok(Some(self.git(&["rev-parse", "HEAD"])?.trim().to_string()))
314    }
315
316    async fn read_events(&self) -> Result<Vec<Event>, StateError> {
317        let path = self.events_path();
318        let raw = match tokio::fs::read_to_string(&path).await {
319            Ok(raw) => raw,
320            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
321            Err(err) => return Err(err.into()),
322        };
323        let numbered: Vec<(usize, &str)> = raw
324            .lines()
325            .enumerate()
326            .filter(|(_, line)| !line.trim().is_empty())
327            .collect();
328        let last = numbered.last().map(|(n, _)| *n);
329        let mut events = Vec::with_capacity(numbered.len());
330        for (n, line) in numbered {
331            match serde_json::from_str(line) {
332                Ok(event) => events.push(event),
333                Err(err) => {
334                    let hint = if Some(n) == last && err.is_eof() {
335                        " (torn final line — likely a crash mid-append; truncate the last line to recover)"
336                    } else {
337                        ""
338                    };
339                    return Err(StateError::Store(format!(
340                        "{}:{}: corrupt event: {err}{hint}",
341                        path.display(),
342                        n + 1
343                    )));
344                }
345            }
346        }
347        Ok(events)
348    }
349}
350
351#[async_trait]
352impl EventStore for GitEventStore {
353    async fn append(&self, events: Vec<Event>) -> Result<Vec<EventId>, StateError> {
354        let _guard = self.append_lock.lock().await;
355        self.acquire_lease()?;
356        let path = self.events_path();
357        let existed = path.exists();
358        let ids = events.iter().map(|event| event.id.clone()).collect();
359        let mut file = tokio::fs::OpenOptions::new()
360            .create(true)
361            .append(true)
362            .open(&path)
363            .await?;
364        for event in events {
365            let mut line = serde_json::to_string(&event)?;
366            line.push('\n');
367            file.write_all(line.as_bytes()).await?;
368        }
369        file.flush().await?;
370        file.sync_all().await?; // durability: the tail survives a crash mid-run
371        if !existed {
372            // First append created the file: fsync the directory so the entry
373            // itself is durable.
374            if let Some(parent) = path.parent() {
375                std::fs::File::open(parent)?.sync_all()?;
376            }
377        }
378        Ok(ids)
379    }
380
381    async fn scan(&self) -> Result<Vec<Event>, StateError> {
382        self.read_events().await
383    }
384
385    async fn scan_after(&self, event_id: Option<EventId>) -> Result<Vec<Event>, StateError> {
386        scan_after_events(self.read_events().await?, event_id)
387    }
388}
389
390/// Convenience for examples/tests: initialize a git repo suitable as an agent
391/// repo (git init + minimal AGENT.md/identity), returning the store.
392pub fn init_agent_repo(
393    root: impl AsRef<Path>,
394    agent_id: &str,
395    worker_id: &str,
396) -> Result<GitEventStore, StateError> {
397    let root = root.as_ref();
398    std::fs::create_dir_all(root)?;
399    let run = |args: &[&str]| -> Result<(), StateError> {
400        let out = Command::new("git")
401            .arg("-C")
402            .arg(root)
403            .args(args)
404            .output()
405            .map_err(|err| StateError::Store(format!("git: {err}")))?;
406        if out.status.success() {
407            Ok(())
408        } else {
409            Err(StateError::Store(format!(
410                "git {args:?}: {}",
411                String::from_utf8_lossy(&out.stderr).trim()
412            )))
413        }
414    };
415    if !root.join(".git").exists() {
416        run(&["init", "-q"])?;
417    }
418    let identity = root.join("identity");
419    std::fs::create_dir_all(&identity)?;
420    let identity_md = identity.join("IDENTITY.md");
421    if !identity_md.exists() {
422        std::fs::write(identity_md, format!("# Identity\n\nI am {agent_id}.\n"))?;
423    }
424    let agent_md = root.join("AGENT.md");
425    if !agent_md.exists() {
426        std::fs::write(
427            agent_md,
428            format!("# AGENT\n\n```yaml\nspec_version: 1\nagent_id: {agent_id}\n```\n"),
429        )?;
430    }
431    GitEventStore::open(root, worker_id)
432}