Use RunFinished event for pipeline outcome instead of exit code
quire-ci now always exits 0 on clean execution, regardless of whether
jobs passed or failed. Pipeline outcome is conveyed by a RunFinished
event (outcome: success | pipeline_failure) written as the last entry
in the event stream. A non-zero exit unambiguously means quire-ci itself
crashed or was killed.

On the server side, ingest_events returns Option<RunOutcome>; the run
transitions to Complete on Success, Failed (failure_kind = pipeline-failure)
on PipelineFailure, and remains on the process-crashed path (failure_kind =
process-crashed) for non-zero exits. The ProcessFailed info/error split
in run_ref is gone — all errors from run_ref_inner are now operational.

https://claude.ai/code/session_0135zw5K7qsn9thYkkpjX6HE
change
commit 4d15ca732c3fba3f90afd8d42765052afa4fb313
author Claude <noreply@anthropic.com>
date
parent fd3d6c99
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index a33495a..58088fc 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -8,7 +8,7 @@ use std::rc::Rc;
 
 use clap::Parser;
 use miette::IntoDiagnostic;
-use quire_core::ci::event::{Event, EventKind, JobOutcome};
+use quire_core::ci::event::{Event, EventKind, JobOutcome, RunOutcome};
 use quire_core::ci::pipeline::{self, Pipeline, RunFn};
 use quire_core::ci::run::RunMeta;
 use quire_core::ci::runtime::{Runtime, RuntimeError, RuntimeEvent, RuntimeHandle};
@@ -547,15 +547,24 @@ fn run_pipeline(
         }
     }
 
-    if let Some((job_id, err)) = failed_job {
-        // Log at warn so it appears in stderr (and the run's CRI log
-        // viewed in the UI) without tripping sentry-tracing's ERROR →
-        // Event mapping. Job failures are user-pipeline issues, not
-        // ops, and miette prints the full diagnostic to stderr on the
-        // returned Err anyway.
-        tracing::warn!(job = %job_id, error = &err as &(dyn std::error::Error + 'static), "job run-fn failed");
-        return Err(err.into());
-    }
+    let run_outcome = if let Some((job_id, err)) = &failed_job {
+        // Log at warn so it appears in stderr (and the run's log viewed
+        // in the UI) without tripping sentry-tracing's ERROR → Event
+        // mapping. Job failures are user-pipeline issues, not ops.
+        tracing::warn!(job = %job_id, error = err as &(dyn std::error::Error + 'static), "job run-fn failed");
+        RunOutcome::PipelineFailure
+    } else {
+        RunOutcome::Success
+    };
+
+    sink.borrow_mut()
+        .emit(Event {
+            at_ms: jiff::Timestamp::now().as_millisecond(),
+            kind: EventKind::RunFinished {
+                outcome: run_outcome,
+            },
+        })
+        .expect("emit run_finished");
 
     Ok(())
 }
diff --git a/quire-core/src/ci/event.rs b/quire-core/src/ci/event.rs
index 847ffb7..5c56e86 100644
--- a/quire-core/src/ci/event.rs
+++ b/quire-core/src/ci/event.rs
@@ -19,6 +19,14 @@ pub enum JobOutcome {
     Failed,
 }
 
+/// Outcome of the complete pipeline run, carried by [`EventKind::RunFinished`].
+#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum RunOutcome {
+    Success,
+    PipelineFailure,
+}
+
 /// A single event in the run's structured output stream.
 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
 pub struct Event {
@@ -41,6 +49,10 @@ pub enum EventKind {
     ShStarted { job_id: String, cmd: String },
     /// An sh process exited.
     ShFinished { job_id: String, exit_code: i32 },
+    /// The pipeline run has finished cleanly. Always the last event in
+    /// the stream — its presence signals that quire-ci ran to completion
+    /// rather than crashing mid-run.
+    RunFinished { outcome: RunOutcome },
 }
 
 #[cfg(test)]
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 9c4a37f..3e94d7d 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -181,9 +181,6 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
 }
 
 /// Set up Sentry trace scope and run CI for a single ref.
-///
-/// `ProcessFailed` is logged at info (user-pipeline failure, visible in the
-/// UI via run state); everything else is logged at error (operational).
 fn run_ref(
     ctx: &TriggerContext<'_>,
     pushed_at: jiff::Timestamp,
@@ -219,21 +216,12 @@ fn run_ref(
                 &transport,
                 sentry_handoff.as_ref(),
             ) {
-                if matches!(e, error::Error::ProcessFailed { .. }) {
-                    tracing::info!(
-                        repo = %ctx.event_repo,
-                        sha = %push_ref.new_sha, // cov-excl-line
-                        error = %e,
-                        "ci run finished with non-zero exit",
-                    );
-                } else {
-                    tracing::error!(
-                        repo = %ctx.event_repo,
-                        sha = %push_ref.new_sha, // cov-excl-line
-                        error = &e as &(dyn std::error::Error + 'static),
-                        "CI trigger failed",
-                    );
-                }
+                tracing::error!(
+                    repo = %ctx.event_repo,
+                    sha = %push_ref.new_sha, // cov-excl-line
+                    error = &e as &(dyn std::error::Error + 'static),
+                    "CI trigger failed",
+                );
             }
         },
     );
@@ -495,7 +483,7 @@ mod tests {
     }
 
     #[test]
-    fn trigger_ref_drives_run_to_complete_with_fake_quire_ci() {
+    fn run_ref_inner_drives_run_to_complete_with_fake_quire_ci() {
         let source = r#"(local ci (require :quire.ci))
 (ci.job :build [:quire/push] (fn [] nil))"#;
         let (_dir, quire, name) = bare_repo_with_ci(source);
@@ -544,7 +532,7 @@ mod tests {
     }
 
     #[test]
-    fn trigger_ref_transitions_to_failed_when_quire_ci_exits_nonzero() {
+    fn run_ref_inner_transitions_to_failed_when_process_crashes() {
         let source = r#"(local ci (require :quire.ci))
 (ci.job :build [:quire/push] (fn [] nil))"#;
         let (_dir, quire, name) = bare_repo_with_ci(source);
@@ -568,7 +556,7 @@ mod tests {
         let err = trigger_result.expect_err("should fail when quire-ci exits nonzero");
         assert!(
             err.to_string().contains("quire-ci exited"),
-            "expected QuireCiExit error, got: {err}"
+            "expected ProcessFailed error, got: {err}"
         );
 
         let conn = crate::db::open(&quire.db_path()).expect("db");
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 7af7ed3..1ed6b73 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -397,26 +397,36 @@ impl Run {
             );
         }
 
-        // Ingest events whether or not the run succeeded — partial
-        // results are still useful in the UI. A failure to read or
-        // parse the file goes to the log but doesn't mask the run's
-        // own pass/fail outcome.
-        if let Err(e) = self.ingest_events(&events_path) {
-            tracing::warn!(
-                run_id = %self.id,
-                error = %e,
-                "failed to ingest quire-ci events; jobs/sh_events rows may be incomplete"
-            );
-        }
+        // Ingest events regardless of exit — partial results are still
+        // useful in the UI. A failure to read or parse goes to the log
+        // but doesn't mask the run's own pass/fail outcome.
+        let run_outcome = match self.ingest_events(&events_path) {
+            Ok(outcome) => outcome,
+            Err(e) => {
+                tracing::warn!(
+                    run_id = %self.id,
+                    error = %e,
+                    "failed to ingest quire-ci events; jobs/sh_events rows may be incomplete"
+                );
+                None
+            }
+        };
 
         if !status.success() {
-            self.transition(RunState::Failed, Some("quire-ci-exit"))?;
+            self.transition(RunState::Failed, Some("process-crashed"))?;
             return Err(Error::ProcessFailed {
                 exit: status.code(),
             });
         }
 
-        self.transition(RunState::Complete, None)?;
+        match run_outcome {
+            Some(quire_core::ci::event::RunOutcome::PipelineFailure) => {
+                self.transition(RunState::Failed, Some("pipeline-failure"))?;
+            }
+            _ => {
+                self.transition(RunState::Complete, None)?;
+            }
+        }
         Ok(())
     }
 
@@ -426,12 +436,12 @@ impl Run {
     /// `(run_id, job_id)` in `jobs`, and the wire format interleaves
     /// sh events with their owning job. Pass 1 inserts every job row
     /// (paired by `job_id`); pass 2 inserts sh events.
-    fn ingest_events(&self, path: &Path) -> Result<()> {
-        use quire_core::ci::event::{Event, EventKind, JobOutcome};
+    fn ingest_events(&self, path: &Path) -> Result<Option<quire_core::ci::event::RunOutcome>> {
+        use quire_core::ci::event::{Event, EventKind, JobOutcome, RunOutcome};
 
         let bytes = match fs_err::read(path) {
             Ok(b) => b,
-            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
             Err(e) => return Err(e.into()),
         };
         let events: Vec<Event> = bytes
@@ -448,6 +458,7 @@ impl Run {
 
         // Pass 1: jobs rows. Pair JobStarted with JobFinished by job_id.
         let mut pending_jobs: HashMap<&str, i64> = HashMap::new();
+        let mut run_outcome: Option<RunOutcome> = None;
         for event in &events {
             match &event.kind {
                 EventKind::JobStarted { job_id } => {
@@ -465,6 +476,9 @@ impl Run {
                         rusqlite::params![&self.id, job_id, state, started_at, event.at_ms],
                     )?;
                 }
+                EventKind::RunFinished { outcome } => {
+                    run_outcome = Some(*outcome);
+                }
                 EventKind::ShStarted { .. } | EventKind::ShFinished { .. } => {}
             }
         }
@@ -488,11 +502,13 @@ impl Run {
                         rusqlite::params![&self.id, job_id, started_at, event.at_ms, exit_code, cmd],
                     )?;
                 }
-                EventKind::JobStarted { .. } | EventKind::JobFinished { .. } => {}
+                EventKind::JobStarted { .. }
+                | EventKind::JobFinished { .. }
+                | EventKind::RunFinished { .. } => {}
             }
         }
 
-        Ok(())
+        Ok(run_outcome)
     }
 
     /// Transition the run from its current state to a new state.
@@ -1220,7 +1236,7 @@ mod tests {
 
     #[test]
     fn ingest_events_writes_jobs_and_sh_events_rows() {
-        use quire_core::ci::event::{Event, EventKind, JobOutcome};
+        use quire_core::ci::event::{Event, EventKind, JobOutcome, RunOutcome};
 
         let (_dir, quire) = tmp_quire();
         let runs = test_runs(&quire);
@@ -1270,6 +1286,12 @@ mod tests {
                     outcome: JobOutcome::Failed,
                 },
             },
+            Event {
+                at_ms: 230,
+                kind: EventKind::RunFinished {
+                    outcome: RunOutcome::PipelineFailure,
+                },
+            },
         ];
 
         let events_path = run.path().join("events.jsonl");
@@ -1280,7 +1302,8 @@ mod tests {
         }
         fs_err::write(&events_path, bytes).expect("write events.jsonl");
 
-        run.ingest_events(&events_path).expect("ingest");
+        let outcome = run.ingest_events(&events_path).expect("ingest");
+        assert_eq!(outcome, Some(RunOutcome::PipelineFailure));
 
         let db = crate::db::open(&quire.db_path()).expect("open db");
         let jobs: Vec<(String, String, i64, i64)> = db
@@ -1336,8 +1359,10 @@ mod tests {
             .expect("create");
 
         let missing = run.path().join("events.jsonl");
-        run.ingest_events(&missing)
+        let outcome = run
+            .ingest_events(&missing)
             .expect("missing file should not error");
+        assert!(outcome.is_none(), "missing file yields no outcome");
 
         let db = crate::db::open(&quire.db_path()).expect("open db");
         let count: i64 = db