Simplify parallel duration and event failure logging

The parallel stage summary rendered a Duration tile directly below
StageMetaBar, which already shows the same stage's duration with a live
ticking clock and a started-at tooltip. The two disagreed while running:
the meta bar counted up, the tile showed the static word "running". The
cancelled-stage bug lived only in the duplicate.

Drop the tile. The meta bar owns duration for every stage renderer, and
it was already correct for cancelled, pending and skipped stages. That
removes the three-way duration branch, the "--" sentinel decode, and the
ACTIVE_STAGE_STATES and formatDurationMs imports.

With the tile gone, ParallelOverview.durationMs is dead, as were
successCount, failureCount and isComplete — the renderer counts the
branch rows it draws. ParallelOverview reduces to branch identity.

For run event write failures, log the first at error with run_id and
event name, the rest at debug, and summarize new losses at flush. A
broken sink fails for every event, so a bare error would emit one
"investigate me" line per event for the life of the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Release Repro 2026-08-01 08:57:47 -04:00
parent 8881bcab3e
commit 8db771bbd0
No known key found for this signature in database
5 changed files with 53 additions and 51 deletions

View file

@ -195,15 +195,11 @@ describe("parseParallelOverview", () => {
const overview = parseParallelOverview(events);
expect(overview).toEqual({
branchCount: 3,
successCount: 2,
failureCount: 1,
durationMs: 12000,
results: [
{ id: "branch-a", index: null, itemLabel: null, status: "succeeded" },
{ id: "branch-b", index: null, itemLabel: null, status: "succeeded" },
{ id: "branch-c", index: null, itemLabel: null, status: "failed" },
],
isComplete: true,
});
});
@ -249,7 +245,6 @@ describe("parseParallelOverview", () => {
}),
];
const overview = parseParallelOverview(events);
expect(overview.isComplete).toBe(false);
expect(overview.branchCount).toBe(4);
expect(overview.results).toEqual([]);
});

View file

@ -172,35 +172,28 @@ export interface ParallelBranchSummary {
export interface ParallelOverview {
branchCount: number | null;
successCount: number | null;
failureCount: number | null;
durationMs: number | null;
results: ParallelBranchSummary[];
isComplete: boolean;
}
/**
* Roll up the `parallel.started` (announces branch count) and
* `parallel.completed` (carries the rolled-up results) events for a parallel
* `parallel.completed` (carries the per-branch results) events for a parallel
* stage. Pre-completion, only the announce data is available.
*
* Only branch identity is parsed. The event's own `success_count`,
* `failure_count` and `duration_ms` rollups are deliberately ignored: the
* renderer counts the branch rows it actually draws, and duration comes from
* the stage record via `StageMetaBar`.
*/
export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview {
let branchCount: number | null = null;
let successCount: number | null = null;
let failureCount: number | null = null;
let durationMs: number | null = null;
let results: ParallelBranchSummary[] = [];
let isComplete = false;
for (const event of events) {
const props: UnknownRecord = event.properties ?? {};
if (event.event === "parallel.started") {
branchCount = getNumber(props, "branch_count") ?? branchCount;
} else if (event.event === "parallel.completed") {
isComplete = true;
successCount = getNumber(props, "success_count") ?? successCount;
failureCount = getNumber(props, "failure_count") ?? failureCount;
durationMs = getNumber(props, "duration_ms") ?? durationMs;
const rawResults = getArray(props, "results") ?? [];
results = rawResults
.map((entry) => {
@ -218,14 +211,7 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview
}
}
return {
branchCount,
successCount,
failureCount,
durationMs,
results,
isComplete,
};
return { branchCount, results };
}
export interface ReducerTranscript {

View file

@ -144,18 +144,23 @@ describe("ParallelChildren", () => {
expect(statValue(renderer, "Failed")).toBe("0");
});
test("uses the stage duration when cancellation interrupts the parallel summary", () => {
test("shows the recorded stage duration when cancellation interrupts the fan-out", () => {
const renderer = renderParallel(
[startedEvent(2)],
[],
{
...parallelStage,
makeStage({
id: "fork@1",
name: "fork",
nodeId: "fork",
handler: "parallel",
status: StageState.CANCELLED,
duration: "53m 29s",
},
}),
);
expect(statValue(renderer, "Duration")).toBe("53m 29s");
// No `parallel.completed` event is emitted for an interrupted fan-out, so
// the stage record is the only duration there is.
expect(textContent(renderer.root)).toContain("53m 29s");
});
test("keeps looped fork links scoped to the selected fork visit", () => {

View file

@ -5,13 +5,7 @@ import { StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import {
ACTIVE_STAGE_STATES,
formatStageLabel,
stageStatusLabel,
stageStatusTone,
} from "../../lib/stage-sidebar";
import { formatDurationMs } from "../../lib/format";
import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { StageMetaBar } from "./meta-bar";
import { parseParallelOverview } from "./helpers";
import type { ParallelBranchSummary } from "./helpers";
@ -187,18 +181,13 @@ export function ParallelChildren({
else if (row.status === StageState.FAILED) failureCount += 1;
}
let duration = stage.duration === "--" ? "—" : stage.duration;
if (overview.durationMs != null) {
duration = formatDurationMs(overview.durationMs);
} else if (ACTIVE_STAGE_STATES.has(stage.status)) {
duration = "running";
}
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
{/* The meta bar owns duration for every stage renderer, including the
live clock while running, so the tiles below stay outcome-only. */}
<StageMetaBar stage={stage} />
<section className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-lg bg-panel p-5 outline-1 -outline-offset-1 outline-line sm:grid-cols-4">
<section className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-lg bg-panel p-5 outline-1 -outline-offset-1 outline-line sm:grid-cols-3">
<StatItem label="Branches" value={branchCount || "—"} />
<StatItem
label="Succeeded"
@ -210,10 +199,6 @@ export function ParallelChildren({
value={failureCount}
tone={failureCount > 0 ? "danger" : "default"}
/>
<StatItem
label="Duration"
value={duration}
/>
</section>
<section>

View file

@ -163,14 +163,45 @@ impl RunEventLogger {
let (tx, mut rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
// A dropped run event is unrecoverable history loss, so the first
// one is an ERROR worth investigating. A broken sink fails for
// every event that follows, so report the rest as a count at flush
// instead of one ERROR per event. Flush runs per stage and per
// agent turn, so only losses since the last summary are reported.
let mut write_failures: u64 = 0;
let mut summarized_failures: u64 = 0;
while let Some(command) = rx.recv().await {
match command {
RunEventCommand::Event(event) => {
if let Err(err) = sink.write_run_event(&event).await {
tracing::error!(error = %err, "Failed to write run event");
write_failures += 1;
if write_failures == 1 {
tracing::error!(
run_id = %event.run_id,
event = %event.body.event_name(),
error = %err,
"Failed to write run event",
);
} else {
tracing::debug!(
run_id = %event.run_id,
event = %event.body.event_name(),
failures = write_failures,
error = %err,
"Failed to write run event",
);
}
}
}
RunEventCommand::Flush(tx) => {
if write_failures > summarized_failures {
tracing::error!(
lost = write_failures - summarized_failures,
total = write_failures,
"Run events were lost to write failures",
);
summarized_failures = write_failures;
}
let _ = tx.send(());
}
}