Make state.yml record timestamps, not run state
The status field duplicated the directory name and went stale
after every transition. Drop it; let transition stamp
started_at/finished_at as the single source for timing.
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
diff --git a/src/ci.rs b/src/ci.rs
index 463410b..3dfbf05 100644
--- a/src/ci.rs
+++ b/src/ci.rs
@@ -38,11 +38,10 @@ pub struct RunMeta {
pub pushed_at: String,
}
-/// Mutable state for a CI run. Updated throughout the run lifecycle.
-#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+/// Timestamps recorded across the run lifecycle. The directory name is the
+/// authoritative state; this file records when transitions happened.
+#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RunStateFile {
- /// Current status of the run.
- pub status: RunState,
/// ISO 8601 timestamp of when the run was picked up (moved to active).
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
@@ -78,14 +77,7 @@ impl Runs {
fs_err::create_dir_all(&tmp_dir)?;
write_yaml(&tmp_dir.join("meta.yml"), meta)?;
- write_yaml(
- &tmp_dir.join("state.yml"),
- &RunStateFile {
- status: RunState::Pending,
- started_at: None,
- finished_at: None,
- },
- )?;
+ write_yaml(&tmp_dir.join("state.yml"), &RunStateFile::default())?;
let final_dir = pending_dir.join(&id);
fs_err::rename(&tmp_dir, &final_dir)?;
@@ -182,18 +174,6 @@ impl Runs {
%e,
"failed to transition orphaned active run to failed"
);
- continue;
- }
- if let Err(e) = orphan.run.write_state(&RunStateFile {
- status: RunState::Failed,
- started_at: orphan.state.started_at.clone(),
- finished_at: Some(jiff::Zoned::now().to_string()),
- }) {
- tracing::error!(
- run_id = %orphan.run.id(),
- %e,
- "failed to write state for failed run"
- );
}
}
_ => unreachable!("scan_orphans only returns pending/active"),
@@ -249,8 +229,9 @@ impl Run {
/// Transition the run from its current state to a new state.
///
- /// Moves the run directory between state parent directories and updates
- /// the tracked state.
+ /// Moves the run directory between state parent directories and stamps
+ /// `started_at` (entering Active) or `finished_at` (entering Complete or
+ /// Failed) on the state file. Each timestamp is set at most once.
pub fn transition(&mut self, to: RunState) -> Result<()> {
let src = self.path();
let dst_parent = self.base.join(to.dir_name());
@@ -266,6 +247,17 @@ impl Run {
let dst = dst_parent.join(&self.id);
fs_err::rename(&src, &dst)?;
self.state = to;
+
+ let mut times = self.read_state()?;
+ let now = || jiff::Zoned::now().to_string();
+ match to {
+ RunState::Active if times.started_at.is_none() => times.started_at = Some(now()),
+ RunState::Complete | RunState::Failed if times.finished_at.is_none() => {
+ times.finished_at = Some(now())
+ }
+ _ => {}
+ }
+ self.write_state(×)?;
Ok(())
}
@@ -522,11 +514,6 @@ fn trigger_ref(repo: &Repo, pushed_at: &str, push_ref: &PushRef) -> Result<()> {
}
Err(e) => {
run.transition(RunState::Failed)?;
- run.write_state(&RunStateFile {
- status: RunState::Failed,
- started_at: None,
- finished_at: Some(jiff::Zoned::now().to_string()),
- })?;
// Return the eval/validation error as the dispatch error.
Err(e)?;
}
@@ -611,8 +598,8 @@ mod tests {
assert_eq!(meta.sha, "abc123");
let state = run.read_state().expect("read state");
- assert_eq!(state.status, RunState::Pending);
assert!(state.started_at.is_none());
+ assert!(state.finished_at.is_none());
}
#[test]
@@ -637,6 +624,55 @@ mod tests {
assert_eq!(run.id(), id);
}
+ #[test]
+ fn transition_stamps_started_at_on_active() {
+ let (_dir, quire) = tmp_quire();
+ let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+ let mut run = runs.create(&test_meta()).expect("create");
+
+ run.transition(RunState::Active).expect("to active");
+ let times = run.read_state().expect("read state");
+ assert!(times.started_at.is_some(), "started_at should be stamped");
+ assert!(times.finished_at.is_none());
+ }
+
+ #[test]
+ fn transition_stamps_finished_at_on_complete_and_failed() {
+ let (_dir, quire) = tmp_quire();
+ let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+
+ let mut run = runs.create(&test_meta()).expect("create");
+ run.transition(RunState::Active).expect("to active");
+ run.transition(RunState::Complete).expect("to complete");
+ let times = run.read_state().expect("read state");
+ assert!(times.started_at.is_some());
+ assert!(times.finished_at.is_some());
+
+ let mut failed = runs.create(&test_meta()).expect("create");
+ failed.transition(RunState::Failed).expect("to failed");
+ let failed_times = failed.read_state().expect("read state");
+ assert!(
+ failed_times.started_at.is_none(),
+ "no started_at when skipping active"
+ );
+ assert!(failed_times.finished_at.is_some());
+ }
+
+ #[test]
+ fn transition_preserves_started_at_through_completion() {
+ let (_dir, quire) = tmp_quire();
+ let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+ let mut run = runs.create(&test_meta()).expect("create");
+
+ run.transition(RunState::Active).expect("to active");
+ let active_times = run.read_state().expect("read state");
+ let started = active_times.started_at.clone();
+
+ run.transition(RunState::Complete).expect("to complete");
+ let complete_times = run.read_state().expect("read state");
+ assert_eq!(complete_times.started_at, started, "started_at preserved");
+ }
+
#[test]
fn transition_full_lifecycle() {
let (_dir, quire) = tmp_quire();
@@ -711,14 +747,12 @@ mod tests {
let run = runs.create(&test_meta()).expect("create");
run.write_state(&RunStateFile {
- status: RunState::Active,
started_at: Some("2026-04-28T12:00:01Z".to_string()),
finished_at: None,
})
.expect("write state");
let loaded = run.read_state().expect("read state");
- assert_eq!(loaded.status, RunState::Active);
assert_eq!(loaded.started_at.as_deref(), Some("2026-04-28T12:00:01Z"));
// Meta is unchanged.