diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index c43846da6..e910be1ee 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -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( ( 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 5ed2decb9..5beee580e 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.test.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -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", () => { diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts index 645e2f415..47587fe2d 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -25,18 +25,27 @@ export interface RunFailureView { type FailureState = Pick; +export interface RunFailureContext { + /** Provider ids to display names, from the provider catalog. */ + providerNames?: ReadonlyMap; + /** 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 = 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; const RETRY = "choose Retry from Actions";