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
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