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
change votlnuuvmvklzxquxxlvrwzqpuuwvnqq
commit 97880c8e307c98fa4a15d1348a9f34f23dcbf22f
author Alpha Chen <alpha@kejadlen.dev>
date
parent xpozwnqr
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(&times)?;
         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.