Drive run outcome from RunFinished event alone, drop exit code check
Since quire-ci exits 0 for all expected outcomes, the exit code is no
longer needed to distinguish pass from fail. RunFinished present means
clean execution (Success → Complete, PipelineFailure → Failed); absent
means the process crashed or was killed (None → process-crashed, Err).
The fake quire-ci in tests now writes RunFinished(success) to the
--events file so the server can resolve the outcome correctly.

https://claude.ai/code/session_0135zw5K7qsn9thYkkpjX6HE
change
commit 86d2cfb998115fd1ea888ee5a460f4516c545242
author Claude <noreply@anthropic.com>
date
parent 872a0edb
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 3e94d7d..06ec670 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -441,8 +441,27 @@ mod tests {
         let dir = tempfile::tempdir().expect("tempdir for fake quire-ci");
         if cfg!(unix) {
             let path = dir.path().join("quire-ci");
-            fs_err::write(&path, format!("#!/bin/sh\nexit {exit_code}\n"))
-                .expect("write fake quire-ci");
+            // For a clean exit, write RunFinished(success) to the --events
+            // file so the server can determine the outcome from events alone.
+            let script = if exit_code == 0 {
+                r#"#!/bin/sh
+events=
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --events) events="$2"; shift 2 ;;
+    *) shift ;;
+  esac
+done
+if [ -n "$events" ] && [ "$events" != "null" ]; then
+  printf '{"at_ms":0,"type":"run_finished","outcome":"success"}\n' > "$events"
+fi
+exit 0
+"#
+                .to_string()
+            } else {
+                format!("#!/bin/sh\nexit {exit_code}\n")
+            };
+            fs_err::write(&path, script).expect("write fake quire-ci");
             use std::os::unix::fs::PermissionsExt;
             fs_err::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
                 .expect("chmod fake quire-ci");
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 1ed6b73..a683c1f 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -397,9 +397,9 @@ impl Run {
             );
         }
 
-        // 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.
+        // Ingest events before checking outcome — partial results from a
+        // crashed run are still useful in the UI. A parse failure is
+        // logged but doesn't mask the run outcome.
         let run_outcome = match self.ingest_events(&events_path) {
             Ok(outcome) => outcome,
             Err(e) => {
@@ -412,19 +412,21 @@ impl Run {
             }
         };
 
-        if !status.success() {
-            self.transition(RunState::Failed, Some("process-crashed"))?;
-            return Err(Error::ProcessFailed {
-                exit: status.code(),
-            });
-        }
-
+        // RunFinished is the sole signal for outcome: present means quire-ci
+        // reached the end cleanly; absent means it crashed or was killed.
+        // The exit code is kept in the error for diagnostics only.
         match run_outcome {
+            Some(quire_core::ci::event::RunOutcome::Success) => {
+                self.transition(RunState::Complete, None)?;
+            }
             Some(quire_core::ci::event::RunOutcome::PipelineFailure) => {
                 self.transition(RunState::Failed, Some("pipeline-failure"))?;
             }
-            _ => {
-                self.transition(RunState::Complete, None)?;
+            None => {
+                self.transition(RunState::Failed, Some("process-crashed"))?;
+                return Err(Error::ProcessFailed {
+                    exit: status.code(),
+                });
             }
         }
         Ok(())