From 285a1d06be6084cb719c83c0096a2f7119cdea39 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 13:59:56 -0400 Subject: [PATCH] Name the failed branch of a fan-out as the stop step The stop step was the latest step to start, and only if it failed, so a sibling branch starting a few milliseconds after the failed one hid it. Name the latest failed step that no later step went on from, where a concurrent sibling branch or the step's own branches don't count as going on. Co-Authored-By: Claude Opus 5.5 --- .../app/routes/run-detail/failure.test.ts | 14 +++++++++ .../app/routes/run-detail/failure.ts | 30 ++++++++++++++----- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/fabro-web/app/routes/run-detail/failure.test.ts b/apps/fabro-web/app/routes/run-detail/failure.test.ts index e3421feb7..7476e598a 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.test.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -135,6 +135,20 @@ describe("buildRunFailureView", () => { expect(failure?.headline).toBe("The steps finished, but publishing the result failed"); }); + test("names the failed branch of a fan-out, though a sibling started after it", () => { + const runStages = stages( + ["checks@1", "failed", "2026-10-10T15:20:00.000Z"], + ["lint@1", "failed", "2026-10-10T15:20:00.010Z"], + ["test@1", "cancelled", "2026-10-10T15:20:00.013Z"], + ); + runStages["lint@1"]!.parallel_branch_id = "checks@1:0"; + runStages["test@1"]!.parallel_branch_id = "checks@1:1"; + expect(view(failedRun({ message: "script exited with status 1" }, { runStages }))?.stage).toBe("lint"); + + runStages["report@1"] = { state: "succeeded", started_at: "2026-10-10T15:21:00Z" } as State["stages"][string]; + expect(view(failedRun({ message: "push rejected" }, { reason: "publish_failed", runStages }))?.stage).toBeNull(); + }); + test("shows nothing for a cancelled run, and says what happened for other endings", () => { expect(view(failedRun({ message: "Run cancelled before worker launch completed" }, { reason: "cancelled" }))).toBeNull(); expect(view(failedRun({ message: "denied at review" }, { reason: "approval_denied" }))?.headline).toBe("Approval was denied"); diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts index 3788db2a2..7e06c09a4 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -207,17 +207,31 @@ function stepAdvice(message: string, category: FailureCategory, output: string | } /** - * The key of the last step to start, if it failed. An earlier failure that a - * later step recovered from isn't where the run stopped, so it isn't named. + * The key of the failed step the run stopped at: the latest failed step that + * no later step went on from. A step that started later in a sibling branch + * of the same fan-out ran beside it, not after it, so it doesn't count; an + * earlier failure that a later step recovered from isn't named. */ function stoppedAt(stages: FailureState["stages"]): string | null { - let last: [string, number] | null = null; - for (const [key, stage] of Object.entries(stages)) { - const started = stage.started_at ? Date.parse(stage.started_at) : NaN; - if (!Number.isNaN(started) && (!last || started > last[1])) last = [key, started]; + const started = Object.entries(stages).flatMap(([key, stage]) => { + const at = stage.started_at ? Date.parse(stage.started_at) : NaN; + if (Number.isNaN(at)) return []; + const branch = stage.parallel_branch_id ?? null; + const group = branch && branch.includes(":") ? branch.slice(0, branch.lastIndexOf(":")) : null; + return [{ key, at, branch, group, failed: stage.state === "failed" }]; + }); + let stop: (typeof started)[number] | null = null; + for (const step of started) { + if (!step.failed) continue; + const wentOn = started.some( + (later) => + later.at > step.at && + later.group !== step.key && + !(step.group !== null && later.group === step.group && later.branch !== step.branch), + ); + if (!wentOn && (!stop || step.at > stop.at)) stop = step; } - if (!last || stages[last[0]]?.state !== "failed") return null; - return last[0]; + return stop?.key ?? null; } function outputTail(tail: ExecOutputTail | null | undefined): string | null {