Show a banner for dead runs, which record no conclusion

A dead run's projection never gets conclusion.failure, so the banner
never showed. Fall back to the run summary's lifecycle.error, where the
server keeps a dead run's reason, and to a generic headline when that is
empty too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-10-27 13:58:17 -04:00
parent 123e0944bd
commit 2e8d11b769
3 changed files with 44 additions and 6 deletions

View file

@ -156,7 +156,10 @@ export default function RunDetail({ params }: { params: { id: string } }) {
() => new Map((providersQuery.data?.data ?? []).map((p) => [p.id, p.display_name || p.id])),
[providersQuery.data],
);
const failure = buildRunFailureView(runStateQuery.data, providerNames);
const failure = buildRunFailureView(runStateQuery.data, {
providerNames,
lifecycleError: runQuery.data?.lifecycle.error?.message,
});
const handleLifecycleMutationResult = useCallback(
(

View file

@ -32,7 +32,7 @@ function failedRun(
};
}
const view = (state: State) => buildRunFailureView(state, NAMES);
const view = (state: State) => buildRunFailureView(state, { providerNames: NAMES });
describe("buildRunFailureView", () => {
test("names the step the run stopped at and the provider's real message", () => {
@ -113,7 +113,20 @@ describe("buildRunFailureView", () => {
expect(view(failedRun({ message: "denied at review" }, { reason: "approval_denied" }))?.headline).toBe("Approval was denied");
expect(view(failedRun({ message: "budget of $5 reached" }, { reason: "budget_exhausted" }))?.headline).toBe("The run used up its budget");
expect(view(failedRun({ message: "sandbox image not found" }, { reason: "sandbox_init_failed" }))?.headline).toBe("The run couldn't start");
expect(view(failedRun({ message: "worker exited unexpectedly" }, { kind: "dead" }))?.headline).toBe("Worker exited unexpectedly");
});
test("says why a dead run stopped, though a dead run records no conclusion", () => {
const dead = {
status: { kind: "dead" },
conclusion: null,
stages: stages(["build@1", "failed", "2026-10-10T18:05:42Z"]),
} as unknown as State;
expect(buildRunFailureView(dead, { lifecycleError: "worker exited unexpectedly" })).toMatchObject({
stage: "build",
headline: "Worker exited unexpectedly",
message: "worker exited unexpectedly",
});
expect(buildRunFailureView(dead)?.headline).toBe("The run's worker stopped before the run finished");
});
test("shows the end of a command's output, from whichever stream has it", () => {

View file

@ -25,18 +25,27 @@ export interface RunFailureView {
type FailureState = Pick<RunProjection, "status" | "conclusion" | "stages">;
export interface RunFailureContext {
/** Provider ids to display names, from the provider catalog. */
providerNames?: ReadonlyMap<string, string>;
/** The run summary's `lifecycle.error`, the only reason a dead run records. */
lifecycleError?: string | null;
}
/**
* Reads the failure from run state (`conclusion.failure`); the run summary the
* status popover reads doesn't carry the message or the command's output.
* status popover reads doesn't carry the message or the command's output. A
* dead run has no conclusion, so its reason comes from the summary instead.
*/
export function buildRunFailureView(
state: FailureState | null | undefined,
providerNames: ReadonlyMap<string, string> = new Map(),
{ providerNames = new Map(), lifecycleError = null }: RunFailureContext = {},
): RunFailureView | null {
const kind = state?.status?.kind;
if (!state || (kind !== "failed" && kind !== "dead")) return null;
const failure = state.conclusion?.failure;
if (!failure?.detail.message || failure.reason === FailureReason.CANCELLED) return null;
if (!failure) return kind === "dead" ? deadRunView(state, lifecycleError) : null;
if (!failure.detail.message || failure.reason === FailureReason.CANCELLED) return null;
const { message, category } = failure.detail;
const output = outputTail(failure.detail.exec_output_tail);
@ -54,6 +63,19 @@ export function buildRunFailureView(
};
}
/** A run whose worker went away before the run concluded. */
function deadRunView(state: FailureState, lifecycleError: string | null): RunFailureView {
const message = lifecycleError?.trim() || "The run ended without recording a reason.";
return {
stage: stoppedAt(state.stages),
headline: lifecycleError?.trim() ? firstSentence(message) : "The run's worker stopped before the run finished",
nextStep: `Open the failed step's logs for details. To run it again, ${RETRY}.`,
message,
provider: null,
output: null,
};
}
type Advice = Pick<RunFailureView, "headline" | "nextStep">;
const RETRY = "choose Retry from Actions";