research-pipeline-diag: state-aware statuses (waiting != failing)
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 37s
ci / rust (push) Successful in 2m53s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m48s

The diagnostic was calling any 0-outcome + 0-completed-runs state a
FAIL — red dot + 'No outcome produced yet — check the runs stage for
the failure reason'. That fires the moment a wizard-materialized
topic lands, before its very first turn even completes, and stays
red for the whole 2-3 min a legitimate coordinator turn runs. Result:
users see 'FAIL' on every fresh topic and can't tell a real failure
from a normal in-flight state.

Backend fix — introduce a `waiting` status (blue/pulsing in UI):

Runs stage:
  0 runs                   -> skip     ('No runs yet — pipeline hasn't fired')
  any running/queued       -> waiting  ('N in flight, M completed')
  all failed               -> fail     (with error text)
  some failed              -> warn
  all completed no fails   -> ok

Outcomes stage:
  outcome_count > 0        -> ok
  0 outcomes + 0 runs      -> skip     ('No outcome yet (pipeline hasn't fired)')
  0 outcomes + any running -> waiting  ('Waiting for the current run to finish…')
  0 outcomes + any failed  -> fail     (the actual silent-bug case)
  0 outcomes + all done ok -> warn     (weird — completed but wrote nothing)

Also suppresses the run stage's `latest_error` detail when the run
status is `waiting` or `skip` — reporting a stale error next to an
actively-running job is what made users think the current run had
failed.

Frontend:
- PipelineStage['status'] union grows a 'waiting' arm.
- Pill color: cyan (#5ec8d8) with a pulsing scale/opacity animation
  (new cm-pulse keyframe in motion.css).
- Strip summary line: 'Pipeline in flight — waiting for run to finish…'
  when there's any waiting stage and no failures.
- Border tint: cyan border when waiting, coral when failing, neutral
  otherwise.

Zero backend semantic changes to the outcome-write path — this is
purely UI truth-telling.
This commit is contained in:
Omar Sobh
2026-07-11 08:38:15 -07:00
parent 6e06bcf136
commit ee05037095
4 changed files with 103 additions and 18 deletions
+78 -11
View File
@@ -122,12 +122,35 @@ pub async fn pipeline_state(
.iter()
.filter(|r| r.try_get::<String, _>("status").ok().as_deref() == Some("failed"))
.count();
let n_running = run_rows
.iter()
.filter(|r| {
matches!(
r.try_get::<String, _>("status").ok().as_deref(),
Some("running") | Some("queued")
)
})
.count();
let n_completed = run_rows
.iter()
.filter(|r| r.try_get::<String, _>("status").ok().as_deref() == Some("completed"))
.count();
let latest_error = run_rows
.iter()
.find_map(|r| r.try_get::<Option<String>, _>("error").ok().flatten())
.filter(|s| !s.is_empty());
// Status rules:
// - 0 runs → skip (nothing to see yet — natural pre-fire state,
// NOT a failure)
// - any running → waiting (blue/spinner in UI — legitimate in-flight
// state)
// - all failed → fail (nothing succeeded)
// - some failed → warn (mixed history)
// - all completed → ok
let run_status = if n_runs == 0 {
"warn"
"skip"
} else if n_running > 0 {
"waiting"
} else if n_failed == n_runs {
"fail"
} else if n_failed > 0 {
@@ -135,29 +158,73 @@ pub async fn pipeline_state(
} else {
"ok"
};
let run_label = if n_runs == 0 {
"No runs yet — pipeline hasn't fired".to_string()
} else if n_running > 0 && n_failed == 0 {
format!("{n_running} in flight, {n_completed} completed")
} else if n_running > 0 {
format!("{n_running} in flight, {n_completed} completed, {n_failed} failed")
} else {
format!("{n_runs} run(s), {n_failed} failed, {n_completed} completed")
};
stages.push(PipelineStage {
key: "runs".into(),
label: format!("{n_runs} run(s), {n_failed} failed"),
label: run_label,
// Suppress the "failure" detail line while runs are still in flight —
// reporting a prior turn's stale error text next to an actively-running
// job reads like the current run failed, which is what triggered the
// "everything looks broken" impression.
status: run_status,
detail: latest_error,
detail: if run_status == "waiting" || run_status == "skip" {
None
} else {
latest_error
},
});
// 5. outcomes — the artifact rows get_artifact reads.
// 5. outcomes — the artifact rows get_artifact reads. Status is
// state-aware: an outcome-less topic with an in-flight run is a
// NORMAL waiting state, not a failure. Only flag `fail` when all
// runs have terminated AND none produced an outcome — the actual
// silent-bug case this diagnostic was designed to catch.
let outcome_count: i64 =
sqlx::query_scalar("SELECT count(*) FROM research_outcomes WHERE topic_id = $1")
.bind(id)
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let outcome_status = if outcome_count > 0 {
"ok"
} else if n_runs == 0 {
"skip"
} else if n_running > 0 {
"waiting"
} else if n_failed > 0 {
"fail"
} else {
"warn"
};
let outcome_label = if outcome_count > 0 {
format!("{outcome_count} outcome(s) written")
} else if n_running > 0 {
"Waiting for the current run to finish…".to_string()
} else if n_runs == 0 {
"No outcome yet (pipeline hasn't fired)".to_string()
} else if n_failed > 0 {
"No outcome — all runs failed".to_string()
} else {
"No outcome yet".to_string()
};
let outcome_detail = if outcome_status == "fail" {
Some("No outcome produced — check the runs stage for the failure reason.".into())
} else {
None
};
stages.push(PipelineStage {
key: "outcomes".into(),
label: format!("{outcome_count} outcome(s) written"),
status: if outcome_count > 0 { "ok" } else { "fail" },
detail: if outcome_count == 0 {
Some("No outcome produced yet — check the runs stage for the failure reason.".into())
} else {
None
},
label: outcome_label,
status: outcome_status,
detail: outcome_detail,
});
// 6. approval — pending publish-approval, if any.