Exit 0 for all expected outcomes in quire-ci run_pipeline
Compile errors and empty pipelines are user-visible outcomes, not
quire-ci crashes. Both now emit RunFinished and return Ok(()) rather
than propagating errors that cause a non-zero exit. A non-zero exit
from quire-ci now exclusively means an unexpected failure (I/O error,
panic, dispatch file unreadable, etc.).

https://claude.ai/code/session_0135zw5K7qsn9thYkkpjX6HE
change
commit 872a0edb11adf7a8b0ee500b8ccaf74b356f9aa6
author Claude <noreply@anthropic.com>
date
parent 4d15ca73
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 58088fc..ff90cb6 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -425,17 +425,40 @@ fn load_dispatch(
 
 fn run_pipeline(
     workspace: PathBuf,
-    sink: Box<dyn EventSink>,
+    mut sink: Box<dyn EventSink>,
     log_dir: PathBuf,
     git_dir: PathBuf,
     meta: RunMeta,
     secrets: HashMap<String, quire_core::secret::SecretString>,
     _transport: TransportArgs,
 ) -> miette::Result<()> {
-    let pipeline = compile_at(&workspace)?;
+    let pipeline = match compile_at(&workspace) {
+        Ok(p) => p,
+        Err(e) => {
+            tracing::warn!(
+                error = &*e as &(dyn std::error::Error + 'static),
+                "ci.fnl failed to compile"
+            );
+            sink.emit(Event {
+                at_ms: jiff::Timestamp::now().as_millisecond(),
+                kind: EventKind::RunFinished {
+                    outcome: RunOutcome::PipelineFailure,
+                },
+            })
+            .expect("emit run_finished");
+            return Ok(());
+        }
+    };
 
     let job_ids: Vec<String> = pipeline.jobs().iter().map(|j| j.id.clone()).collect();
     if job_ids.is_empty() {
+        sink.emit(Event {
+            at_ms: jiff::Timestamp::now().as_millisecond(),
+            kind: EventKind::RunFinished {
+                outcome: RunOutcome::Success,
+            },
+        })
+        .expect("emit run_finished");
         return Ok(());
     }