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() .iter()
.filter(|r| r.try_get::<String, _>("status").ok().as_deref() == Some("failed")) .filter(|r| r.try_get::<String, _>("status").ok().as_deref() == Some("failed"))
.count(); .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 let latest_error = run_rows
.iter() .iter()
.find_map(|r| r.try_get::<Option<String>, _>("error").ok().flatten()) .find_map(|r| r.try_get::<Option<String>, _>("error").ok().flatten())
.filter(|s| !s.is_empty()); .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 { let run_status = if n_runs == 0 {
"warn" "skip"
} else if n_running > 0 {
"waiting"
} else if n_failed == n_runs { } else if n_failed == n_runs {
"fail" "fail"
} else if n_failed > 0 { } else if n_failed > 0 {
@@ -135,29 +158,73 @@ pub async fn pipeline_state(
} else { } else {
"ok" "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 { stages.push(PipelineStage {
key: "runs".into(), 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, 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 = let outcome_count: i64 =
sqlx::query_scalar("SELECT count(*) FROM research_outcomes WHERE topic_id = $1") sqlx::query_scalar("SELECT count(*) FROM research_outcomes WHERE topic_id = $1")
.bind(id) .bind(id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
.unwrap_or(0); .unwrap_or(0);
stages.push(PipelineStage { let outcome_status = if outcome_count > 0 {
key: "outcomes".into(), "ok"
label: format!("{outcome_count} outcome(s) written"), } else if n_runs == 0 {
status: if outcome_count > 0 { "ok" } else { "fail" }, "skip"
detail: if outcome_count == 0 { } else if n_running > 0 {
Some("No outcome produced yet — check the runs stage for the failure reason.".into()) "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 { } else {
None None
}, };
stages.push(PipelineStage {
key: "outcomes".into(),
label: outcome_label,
status: outcome_status,
detail: outcome_detail,
}); });
// 6. approval — pending publish-approval, if any. // 6. approval — pending publish-approval, if any.
@@ -402,7 +402,13 @@ export function ResearchCanvas({
width: "100%", width: "100%",
padding: "10px 12px", padding: "10px 12px",
borderRadius: 10, borderRadius: 10,
border: `1px solid ${pipeline.stages.some((s) => s.status === "fail") ? "rgba(255,138,122,.4)" : "rgba(255,255,255,.08)"}`, border: `1px solid ${
pipeline.stages.some((s) => s.status === "fail")
? "rgba(255,138,122,.4)"
: pipeline.stages.some((s) => s.status === "waiting")
? "rgba(94,200,216,.35)"
: "rgba(255,255,255,.08)"
}`,
background: pipeline.stages.some((s) => s.status === "fail") background: pipeline.stages.some((s) => s.status === "fail")
? "rgba(255,138,122,.06)" ? "rgba(255,138,122,.06)"
: "#101014", : "#101014",
@@ -426,19 +432,25 @@ export function ResearchCanvas({
borderRadius: "50%", borderRadius: "50%",
background: background:
s.status === "ok" ? "#5fd08a" s.status === "ok" ? "#5fd08a"
: s.status === "waiting" ? "#5ec8d8"
: s.status === "warn" ? "#ffb44a" : s.status === "warn" ? "#ffb44a"
: s.status === "fail" ? "#ff8a7a" : s.status === "fail" ? "#ff8a7a"
: "#4a4a52", : "#4a4a52",
animation: s.status === "waiting" ? "cm-pulse 1.4s ease-in-out infinite" : undefined,
}} }}
/> />
))} ))}
</span> </span>
<span style={{ flex: 1, color: "#8a8a92" }}> <span style={{ flex: 1, color: "#8a8a92" }}>
{pipeline.stages.filter((s) => s.status === "fail").length > 0 {(() => {
? `${pipeline.stages.filter((s) => s.status === "fail").length} failed stage(s) — click for details` const failed = pipeline.stages.filter((s) => s.status === "fail").length;
: pipeline.stages.filter((s) => s.status === "warn").length > 0 const waiting = pipeline.stages.filter((s) => s.status === "waiting").length;
? `${pipeline.stages.filter((s) => s.status === "warn").length} warning(s)` const warn = pipeline.stages.filter((s) => s.status === "warn").length;
: "All stages ok"} if (failed > 0) return `${failed} failed stage(s) — click for details`;
if (waiting > 0) return "Pipeline in flight — waiting for run to finish…";
if (warn > 0) return `${warn} warning(s)`;
return "All stages ok";
})()}
</span> </span>
<span style={{ opacity: 0.7 }}>{diagOpen ? "▾" : "▸"}</span> <span style={{ opacity: 0.7 }}>{diagOpen ? "▾" : "▸"}</span>
</button> </button>
+3 -1
View File
@@ -165,7 +165,9 @@ export const approvePublish = (id: string) =>
export interface PipelineStage { export interface PipelineStage {
key: string; key: string;
label: string; label: string;
status: "ok" | "warn" | "fail" | "skip"; /** waiting = a run is legitimately in flight; NOT a failure state.
* Rendered as a pulsing cyan dot to distinguish from red "fail". */
status: "ok" | "warn" | "fail" | "skip" | "waiting";
detail?: string; detail?: string;
} }
+4
View File
@@ -54,6 +54,10 @@
50% { background-position: 100% 50%; } 50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; } 100% { background-position: 0% 50%; }
} }
@keyframes cm-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.85); }
}
@keyframes caret-blink { @keyframes caret-blink {
0%, 45% { opacity: 1; } 0%, 45% { opacity: 1; }
50%, 95% { opacity: 0; } 50%, 95% { opacity: 0; }