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 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-10-27 13:59:56 -04:00
parent 3107793f2b
commit 285a1d06be
2 changed files with 36 additions and 8 deletions

View file

@ -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");

View file

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