1use 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 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 append_lock: Arc<tokio::sync::Mutex<()>>,
85}
86
87impl GitEventStore {
88 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 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 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; };
185 if existing.is_held_by(&self.worker_id) {
186 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 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; }
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 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 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); }
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?; if !existed {
372 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
390pub 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}