From 123e0944bd2929ce12bfb0e31fbf278744ec3cc6 Mon Sep 17 00:00:00 2001 From: James Robert Somers Date: Mon, 26 Oct 2026 07:57:05 -0400 Subject: [PATCH 01/11] Say why a failed run stopped, above its tabs The status popover shows the reason code ("Workflow error") and lifecycle.error, which is empty when the failure lives in the run's state, so a run stopped by the provider's billing refusal showed no reason anywhere. The message is in conclusion.failure.detail, which the run page already loads. Show a quiet banner on failed and dead runs: the step the run stopped at, a headline and the next thing to do, with the verbatim message and the tail of a failed command's output in a closed
. The advice comes from the failure's reason and category first (approval denied, budget, publish, launch), then from the structured model request message the server records (provider, kind, status, code), then from a command's exit status. Command output is never searched for provider or billing words. Provider names come from /providers, cancelled runs get no banner, and a step that failed earlier but was recovered from isn't named as the stop. --- apps/fabro-web/app/routes/run-detail.test.ts | 1 + apps/fabro-web/app/routes/run-detail.tsx | 13 +- .../app/routes/run-detail/failure-banner.tsx | 62 ++++++ .../app/routes/run-detail/failure.test.ts | 139 ++++++++++++++ .../app/routes/run-detail/failure.ts | 179 ++++++++++++++++++ 5 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 apps/fabro-web/app/routes/run-detail/failure-banner.tsx create mode 100644 apps/fabro-web/app/routes/run-detail/failure.test.ts create mode 100644 apps/fabro-web/app/routes/run-detail/failure.ts diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index 5c54733d9..76ccc0a0c 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -86,6 +86,7 @@ mock.module("../lib/queries", () => ({ isValidating: false, mutate: mock(() => Promise.resolve(null)), }), + useProviders: () => ({ data: null }), })); mock.module("../lib/run-events", () => ({ diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 4ebf6ed03..c43846da6 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -1,5 +1,6 @@ import { useCallback, + useMemo, useRef, useState, type CSSProperties, @@ -34,7 +35,7 @@ import { } from "../lib/mutations"; import { useRunEvents } from "../lib/run-events"; import { useRunToasts } from "../hooks/use-run-toasts"; -import { useRun, useRunQuestions, useRunState } from "../lib/queries"; +import { useProviders, useRun, useRunQuestions, useRunState } from "../lib/queries"; import { canApprove, deleteErrorMessage, @@ -50,6 +51,8 @@ import { RunDetailAskFabroShell, RunDetailDockedControls, } from "./run-detail/docked-controls"; +import { buildRunFailureView } from "./run-detail/failure"; +import { RunFailureBanner } from "./run-detail/failure-banner"; import { RunDetailHeader } from "./run-detail/header"; import { createLifecycleToastState, @@ -148,6 +151,12 @@ export default function RunDetail({ params }: { params: { id: string } }) { useRunEvents(params.id); useRunToasts(params.id); + const providersQuery = useProviders(); + const providerNames = useMemo( + () => new Map((providersQuery.data?.data ?? []).map((p) => [p.id, p.display_name || p.id])), + [providersQuery.data], + ); + const failure = buildRunFailureView(runStateQuery.data, providerNames); const handleLifecycleMutationResult = useCallback( ( @@ -382,6 +391,8 @@ export default function RunDetail({ params }: { params: { id: string } }) { }} /> + {failure ? : null} + {retryDialogOpen && ( +
+ + +
+
+
+ {failure.provider ? "Provider message" : "Message"} +
+
+ {failure.message} +
+
+ {failure.output ? ( +
+
+ Output +
+
+
+                  {failure.output}
+                
+
+
+ ) : null} +
+
+ + ); +} diff --git a/apps/fabro-web/app/routes/run-detail/failure.test.ts b/apps/fabro-web/app/routes/run-detail/failure.test.ts new file mode 100644 index 000000000..5ed2decb9 --- /dev/null +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import type { RunProjection } from "@qltysh/fabro-api-client"; + +import { buildRunFailureView } from "./failure"; + +type State = Pick; + +const NAMES = new Map([ + ["anthropic", "Anthropic"], + ["openai", "OpenAI"], +]); + +const CREDIT = + "model request failed (invalid_request): provider anthropic Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits. [provider anthropic, status 400, code invalid_request_error]"; + +function stages(...entries: Array<[string, string, string]>): State["stages"] { + return Object.fromEntries( + entries.map(([key, state, started_at]) => [key, { state, started_at } as State["stages"][string]]), + ); +} + +function failedRun( + detail: Record, + { reason = "workflow_error", kind = "failed", runStages = {} as State["stages"] } = {}, +): State { + return { + status: (kind === "failed" ? { kind, reason } : { kind }) as State["status"], + conclusion: { + failure: { reason, detail: { category: "deterministic", ...detail } }, + } as State["conclusion"], + stages: runStages, + }; +} + +const view = (state: State) => buildRunFailureView(state, NAMES); + +describe("buildRunFailureView", () => { + test("names the step the run stopped at and the provider's real message", () => { + const failure = view( + failedRun({ message: CREDIT }, { + runStages: stages(["pick@1", "succeeded", "2026-10-10T18:05:42.783Z"], ["spec@1", "failed", "2026-10-10T18:41:53.513Z"]), + }), + ); + expect(failure).toMatchObject({ + stage: "spec", + headline: "Anthropic declined the request for billing reasons", + provider: "anthropic", + message: CREDIT, + }); + expect(failure?.nextStep).toBe("Add credit or raise the spend limit with Anthropic, then choose Retry from Actions."); + }); + + test("names an unknown provider by its id", () => { + const failure = view( + failedRun({ message: "model request failed (authentication): provider acme bad key [provider acme, status 401, code unauthorized]" }), + ); + expect(failure?.headline).toBe("acme rejected the API key"); + }); + + test("reads auth, rate limits, timeouts and outages from the request's own status and kind", () => { + const headline = (message: string) => view(failedRun({ message }))?.headline; + expect(headline("model request failed (authentication): provider anthropic invalid x-api-key [provider anthropic, status 401, code authentication_error]")) + .toBe("Anthropic rejected the API key"); + expect(headline("model request failed (rate_limit): provider openai Too many requests [provider openai, status 429, code rate_limit_exceeded]")) + .toBe("OpenAI rate-limited the request"); + expect(headline("model request failed (server): provider anthropic Overloaded [provider anthropic, status 529, code overloaded_error]")) + .toBe("Anthropic was unavailable"); + expect(headline("model request failed (timeout): provider openai request timed out")) + .toBe("The request to OpenAI timed out"); + }); + + test("falls back to the provider's status and code", () => { + const failure = view( + failedRun({ + message: "model request failed (invalid_request): provider openai Unsupported parameter: 'temperature' is not supported with this model. [provider openai, status 400, code unsupported_parameter]", + }), + ); + expect(failure?.headline).toBe("OpenAI returned 400 unsupported parameter"); + }); + + test("never reads a provider, status or billing problem out of a command's own words", () => { + const failure = view( + failedRun({ + message: "script exited with status 401", + exec_output_tail: { stdout: "test billing::invoice ... FAILED\nretry the provider list later\n", stderr: null }, + }), + ); + expect(failure).toMatchObject({ + headline: "The step's command exited with status 401", + provider: null, + nextStep: "Its output, under Details, usually says why.", + }); + expect(view(failedRun({ message: "Ran 512 tests; line 503 failed." }))?.headline).toBe("Ran 512 tests; line 503 failed."); + }); + + test("names no step when the run recovered from an earlier failed one", () => { + const failure = view( + failedRun({ message: "push rejected: remote branch moved" }, { + reason: "publish_failed", + runStages: stages( + ["toolchain@1", "failed", "2026-10-10T15:20:45Z"], + ["fixup@1", "succeeded", "2026-10-10T15:21:00Z"], + ["verify@1", "succeeded", "2026-10-10T15:25:00.500Z"], + ), + }), + ); + expect(failure?.stage).toBeNull(); + expect(failure?.headline).toBe("The steps finished, but publishing the result failed"); + }); + + 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"); + 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("shows the end of a command's output, from whichever stream has it", () => { + const tail = (exec_output_tail: Record) => + view(failedRun({ message: "script exited with status 1", exec_output_tail }))?.output; + expect(tail({ stderr: "\n \n", stdout: "real error\n" })).toBe("real error"); + expect(tail({ stderr: "line one\r\nline two\r\n", stdout: null })).toBe("line one\nline two"); + expect(tail({ stdout: "10%\r50%\r100% done\n" })).toBe("100% done"); + const many = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n"); + expect(tail({ stdout: many })?.split("\n")).toEqual(Array.from({ length: 12 }, (_, i) => `line ${i + 19}`)); + }); + + test("cuts a long fallback headline without splitting a character", () => { + const headline = view(failedRun({ message: `${"๐Ÿ”ฅ".repeat(200)} and more` }))!.headline; + expect(Array.from(headline)).toHaveLength(111); + expect(headline.endsWith("๐Ÿ”ฅโ€ฆ")).toBe(true); + }); + + test("shows nothing for runs that didn't fail", () => { + expect(buildRunFailureView({ status: { kind: "succeeded" }, stages: {} } as State)).toBeNull(); + expect(buildRunFailureView(null)).toBeNull(); + }); +}); diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts new file mode 100644 index 000000000..645e2f415 --- /dev/null +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -0,0 +1,179 @@ +import { + FailureCategory, + FailureReason, + type ExecOutputTail, + type RunProjection, +} from "@qltysh/fabro-api-client"; + +/** Lines of a failed command's output shown on the page. */ +const OUTPUT_LINES = 12; +/** Fallback headlines are cut to this many characters. */ +const HEADLINE_CHARS = 110; + +export interface RunFailureView { + /** The step the run stopped at (`spec`, `review@3`), when it stopped at one. */ + stage: string | null; + headline: string; + nextStep: string; + /** The recorded failure message, verbatim. */ + message: string; + /** Provider id, when the failure is a model request the provider refused. */ + provider: string | null; + /** Last OUTPUT_LINES of the failed command's stderr, else its stdout. */ + output: string | null; +} + +type FailureState = Pick; + +/** + * 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. + */ +export function buildRunFailureView( + state: FailureState | null | undefined, + providerNames: ReadonlyMap = new Map(), +): 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; + + const { message, category } = failure.detail; + const output = outputTail(failure.detail.exec_output_tail); + const request = parseModelRequest(message); + const name = (id: string) => providerNames.get(id) ?? id; + const advice = + reasonAdvice(failure.reason, category) ?? + (request ? requestAdvice(request, message, name(request.provider)) : stepAdvice(message, category, output)); + return { + stage: stoppedAt(state.stages), + ...advice, + message, + provider: request?.provider ?? null, + output, + }; +} + +type Advice = Pick; + +const RETRY = "choose Retry from Actions"; + +function reasonAdvice(reason: FailureReason, category: FailureCategory): Advice | null { + if (reason === FailureReason.APPROVAL_DENIED) { + return { headline: "Approval was denied", nextStep: `The run stops where it was denied. To run it again, ${RETRY}.` }; + } + if (reason === FailureReason.BUDGET_EXHAUSTED || category === FailureCategory.BUDGET_EXHAUSTED) { + return { headline: "The run used up its budget", nextStep: `Raise the run's budget, then ${RETRY}.` }; + } + if (reason === FailureReason.TERMINATED) { + return { headline: "The run was terminated", nextStep: `To run it again, ${RETRY}.` }; + } + if (reason === FailureReason.PUBLISH_FAILED) { + return { + headline: "The steps finished, but publishing the result failed", + nextStep: "The work is on the run branch. The message under Details says why publishing failed.", + }; + } + if ( + reason === FailureReason.LAUNCH_FAILED || + reason === FailureReason.BOOTSTRAP_FAILED || + reason === FailureReason.SANDBOX_INIT_FAILED + ) { + return { headline: "The run couldn't start", nextStep: "The message under Details says why." }; + } + return null; +} + +interface ModelRequest { + provider: string; + kind: string | null; + status: number | null; + code: string | null; +} + +/** + * A refused model request, as the server records it: + * `model request failed (): provider [provider , status , code ]`. + * Only this shape is read, so numbers and words in a command's output can't + * pass for a provider's status or name. + */ +function parseModelRequest(message: string): ModelRequest | null { + const lead = /^model request failed \(([a-z_]+)\): provider (\S+)/i.exec(message); + const tail = /\[provider ([^,\]]+), status (\d{3})(?:, code ([^\]]+))?\]\s*$/i.exec(message); + const provider = tail?.[1] ?? lead?.[2]; + if (!provider) return null; + return { + provider: provider.toLowerCase(), + kind: lead?.[1]?.toLowerCase() ?? null, + status: tail ? Number(tail[2]) : null, + code: tail?.[3] ?? null, + }; +} + +function requestAdvice({ kind, status, code }: ModelRequest, message: string, who: string): Advice { + if (/credit balance|insufficient[_ ]quota|payment required|billing|spend limit/i.test(message) || status === 402) { + return { + headline: `${who} declined the request for billing reasons`, + nextStep: `Add credit or raise the spend limit with ${who}, then ${RETRY}.`, + }; + } + if (kind === "authentication" || status === 401 || status === 403) { + return { headline: `${who} rejected the API key`, nextStep: `Check the key under Settings โ†’ Secrets, then ${RETRY}.` }; + } + if (kind === "rate_limit" || status === 429) { + return { headline: `${who} rate-limited the request`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." }; + } + if (kind === "timeout" || /timed out/i.test(message)) { + return { headline: `The request to ${who} timed out`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." }; + } + if ((status !== null && status >= 500) || /overloaded/i.test(message)) { + return { headline: `${who} was unavailable`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." }; + } + const what = [status, code?.replace(/_/g, " ")].filter(Boolean).join(" "); + return { + headline: what ? `${who} returned ${what}` : `${who} rejected the request`, + nextStep: `${who}'s message, under Details, says why.`, + }; +} + +function stepAdvice(message: string, category: FailureCategory, output: string | null): Advice { + const exit = /exited with (?:status|code) (\d+)/i.exec(message); + if (exit) { + return { + headline: `The step's command exited with status ${exit[1]}`, + nextStep: output ? "Its output, under Details, usually says why." : "Open the step's logs for its output.", + }; + } + if (category === FailureCategory.TRANSIENT_INFRA) { + return { headline: "The step hit a temporary infrastructure error", nextStep: "Choose Retry from Actions in a few minutes." }; + } + return { headline: firstSentence(message), nextStep: "Open the failed step's logs for details." }; +} + +/** + * 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. + */ +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]; + } + if (!last || stages[last[0]]?.state !== "failed") return null; + return last[0].replace(/@1$/, ""); +} + +function outputTail(tail: ExecOutputTail | null | undefined): string | null { + const stream = [tail?.stderr, tail?.stdout].find((text) => text?.trim()); + if (!stream) return null; + // Carriage-return progress lines keep only what was drawn last. + const lines = stream.trimEnd().split(/\r?\n/).map((line) => line.slice(line.lastIndexOf("\r") + 1)); + return lines.slice(-OUTPUT_LINES).join("\n"); +} + +function firstSentence(message: string): string { + const sentence = message.split(/(?<=[.!?])\s+/)[0]!.trim().replace(/\s+/g, " "); + const chars = Array.from(sentence.charAt(0).toUpperCase() + sentence.slice(1)); + return chars.length > HEADLINE_CHARS ? `${chars.slice(0, HEADLINE_CHARS).join("").trimEnd()}โ€ฆ` : chars.join(""); +} From 2e8d11b769a0b4f017db8b34906a169fd1dd0876 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 13:58:17 -0400 Subject: [PATCH 02/11] 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 --- apps/fabro-web/app/routes/run-detail.tsx | 5 +++- .../app/routes/run-detail/failure.test.ts | 17 +++++++++-- .../app/routes/run-detail/failure.ts | 28 +++++++++++++++++-- 3 files changed, 44 insertions(+), 6 deletions(-) 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"; From f85866c65bc41a07344aa9211e6ac60bb03da8a9 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 13:58:44 -0400 Subject: [PATCH 03/11] Show the failed step's own output when no output tail is recorded The server never fills conclusion.failure.detail.exec_output_tail, so the Output section never appeared. Fall back to the output the stopped stage recorded in the run state, skipping offloaded blob references and model request failures, whose step output is the agent's text. Co-Authored-By: Claude Opus 5.5 --- .../app/routes/run-detail/failure.test.ts | 16 ++++++++++ .../app/routes/run-detail/failure.ts | 30 +++++++++++++------ 2 files changed, 37 insertions(+), 9 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 5beee580e..b24eefd87 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.test.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -139,6 +139,22 @@ describe("buildRunFailureView", () => { expect(tail({ stdout: many })?.split("\n")).toEqual(Array.from({ length: 12 }, (_, i) => `line ${i + 19}`)); }); + test("falls back to the output the failed step recorded", () => { + const runStages = stages(["test@1", "failed", "2026-10-10T18:05:42Z"]); + runStages["test@1"]!.output = "running 3 tests\ntest parse ... FAILED\n"; + const failure = view(failedRun({ message: "script exited with status 101" }, { runStages })); + expect(failure).toMatchObject({ + output: "running 3 tests\ntest parse ... FAILED", + nextStep: "Its output, under Details, usually says why.", + }); + + runStages["test@1"]!.output = "blob://sha256/abc"; + expect(view(failedRun({ message: "script exited with status 101" }, { runStages }))?.output).toBeNull(); + + runStages["test@1"]!.output = "I will now write the spec."; + expect(view(failedRun({ message: CREDIT }, { runStages }))?.output).toBeNull(); + }); + test("cuts a long fallback headline without splitting a character", () => { const headline = view(failedRun({ message: `${"๐Ÿ”ฅ".repeat(200)} and more` }))!.headline; expect(Array.from(headline)).toHaveLength(111); diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts index 47587fe2d..7e3618f48 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -19,7 +19,10 @@ export interface RunFailureView { message: string; /** Provider id, when the failure is a model request the provider refused. */ provider: string | null; - /** Last OUTPUT_LINES of the failed command's stderr, else its stdout. */ + /** + * Last OUTPUT_LINES of the failed command's stderr, else its stdout, else + * the output the step the run stopped at recorded. + */ output: string | null; } @@ -48,14 +51,19 @@ export function buildRunFailureView( if (!failure.detail.message || failure.reason === FailureReason.CANCELLED) return null; const { message, category } = failure.detail; - const output = outputTail(failure.detail.exec_output_tail); const request = parseModelRequest(message); + const stage = stoppedAt(state.stages); + // A refused model request's message is the whole story; the step's + // output would be the agent's own text. + const output = + outputTail(failure.detail.exec_output_tail) ?? + (request || !stage ? null : lastLines(state.stages[stage]?.output)); const name = (id: string) => providerNames.get(id) ?? id; const advice = reasonAdvice(failure.reason, category) ?? (request ? requestAdvice(request, message, name(request.provider)) : stepAdvice(message, category, output)); return { - stage: stoppedAt(state.stages), + stage: stage?.replace(/@1$/, "") ?? null, ...advice, message, provider: request?.provider ?? null, @@ -67,7 +75,7 @@ export function buildRunFailureView( function deadRunView(state: FailureState, lifecycleError: string | null): RunFailureView { const message = lifecycleError?.trim() || "The run ended without recording a reason."; return { - stage: stoppedAt(state.stages), + stage: stoppedAt(state.stages)?.replace(/@1$/, "") ?? null, 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, @@ -173,8 +181,8 @@ function stepAdvice(message: string, category: FailureCategory, output: string | } /** - * 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 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. */ function stoppedAt(stages: FailureState["stages"]): string | null { let last: [string, number] | null = null; @@ -183,12 +191,16 @@ function stoppedAt(stages: FailureState["stages"]): string | null { if (!Number.isNaN(started) && (!last || started > last[1])) last = [key, started]; } if (!last || stages[last[0]]?.state !== "failed") return null; - return last[0].replace(/@1$/, ""); + return last[0]; } function outputTail(tail: ExecOutputTail | null | undefined): string | null { - const stream = [tail?.stderr, tail?.stdout].find((text) => text?.trim()); - if (!stream) return null; + return lastLines([tail?.stderr, tail?.stdout].find((text) => text?.trim())); +} + +function lastLines(stream: string | null | undefined): string | null { + // An offloaded output is a `blob://` reference, not text to show. + if (!stream?.trim() || stream.startsWith("blob://")) return null; // Carriage-return progress lines keep only what was drawn last. const lines = stream.trimEnd().split(/\r?\n/).map((line) => line.slice(line.lastIndexOf("\r") + 1)); return lines.slice(-OUTPUT_LINES).join("\n"); From 16abf08fa7a43ca4f5007854d8da149a64dd44e8 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 13:58:53 -0400 Subject: [PATCH 04/11] Read lithos-llm's quota_exceeded kind as a billing failure A spent quota can come back as HTTP 429, which the banner showed as a temporary rate limit and told the user to retry. lithos-llm marks it quota_exceeded, which backoff never clears, so give it the billing wording. Co-Authored-By: Claude Opus 5.5 --- apps/fabro-web/app/routes/run-detail/failure.test.ts | 9 +++++++++ apps/fabro-web/app/routes/run-detail/failure.ts | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) 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 b24eefd87..f489e7b76 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.test.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -50,6 +50,15 @@ describe("buildRunFailureView", () => { expect(failure?.nextStep).toBe("Add credit or raise the spend limit with Anthropic, then choose Retry from Actions."); }); + test("reads a spent quota as billing, though the provider answered 429", () => { + const failure = view( + failedRun({ + message: "model request failed (quota_exceeded): provider gemini Quota exceeded for metric generate_content_requests [provider gemini, status 429, code RESOURCE_EXHAUSTED]", + }), + ); + expect(failure?.headline).toBe("gemini declined the request for billing reasons"); + }); + test("names an unknown provider by its id", () => { const failure = view( failedRun({ message: "model request failed (authentication): provider acme bad key [provider acme, status 401, code unauthorized]" }), diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts index 7e3618f48..35a76d081 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -141,7 +141,11 @@ function parseModelRequest(message: string): ModelRequest | null { } function requestAdvice({ kind, status, code }: ModelRequest, message: string, who: string): Advice { - if (/credit balance|insufficient[_ ]quota|payment required|billing|spend limit/i.test(message) || status === 402) { + if ( + kind === "quota_exceeded" || + status === 402 || + /credit balance|insufficient[_ ]quota|payment required|billing|spend limit/i.test(message) + ) { return { headline: `${who} declined the request for billing reasons`, nextStep: `Add credit or raise the spend limit with ${who}, then ${RETRY}.`, From fdfa016c3a873c39ea2c54886e004844974a7269 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 13:59:04 -0400 Subject: [PATCH 05/11] Don't blame the API key for an access_denied refusal Every 403 was reported as a rejected API key, so a user whose key works but whose account isn't allowed the model or region would rotate a good key. Only lithos-llm's authentication kind or a 401 gets the API-key wording; access_denied and other 403s say access was denied. Co-Authored-By: Claude Opus 5.5 --- apps/fabro-web/app/routes/run-detail/failure.test.ts | 4 ++++ apps/fabro-web/app/routes/run-detail/failure.ts | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) 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 f489e7b76..c5d575ab1 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.test.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -70,6 +70,10 @@ describe("buildRunFailureView", () => { const headline = (message: string) => view(failedRun({ message }))?.headline; expect(headline("model request failed (authentication): provider anthropic invalid x-api-key [provider anthropic, status 401, code authentication_error]")) .toBe("Anthropic rejected the API key"); + expect(headline("model request failed (access_denied): provider openai The model is not available to your organization [provider openai, status 403, code model_not_found]")) + .toBe("OpenAI denied access to the request"); + expect(headline("model request failed (authentication): provider acme forbidden [provider acme, status 403, code forbidden]")) + .toBe("acme rejected the API key"); expect(headline("model request failed (rate_limit): provider openai Too many requests [provider openai, status 429, code rate_limit_exceeded]")) .toBe("OpenAI rate-limited the request"); expect(headline("model request failed (server): provider anthropic Overloaded [provider anthropic, status 529, code overloaded_error]")) diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts index 35a76d081..3f8080d65 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -151,9 +151,15 @@ function requestAdvice({ kind, status, code }: ModelRequest, message: string, wh nextStep: `Add credit or raise the spend limit with ${who}, then ${RETRY}.`, }; } - if (kind === "authentication" || status === 401 || status === 403) { + if (kind === "authentication" || status === 401) { return { headline: `${who} rejected the API key`, nextStep: `Check the key under Settings โ†’ Secrets, then ${RETRY}.` }; } + if (kind === "access_denied" || status === 403) { + return { + headline: `${who} denied access to the request`, + nextStep: `The key works, but ${who} doesn't allow this account to make the call. ${who}'s message, under Details, says why.`, + }; + } if (kind === "rate_limit" || status === 429) { return { headline: `${who} rate-limited the request`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." }; } From 3107793f2b9ee7cfd2b284681cc73d49a05e23a8 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 13:59:27 -0400 Subject: [PATCH 06/11] Don't report Fabro-side model request failures as the provider's refusal A configuration, model-selection, network, middleware or resource-limit error names a provider but never got an answer from it, yet the banner said the provider rejected the request. Give these kinds wording that points at Fabro's catalog, the network, or Fabro's own limits. Co-Authored-By: Claude Opus 5.5 --- .../app/routes/run-detail/failure.test.ts | 14 ++++++++++++++ apps/fabro-web/app/routes/run-detail/failure.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) 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 c5d575ab1..e3421feb7 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.test.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -82,6 +82,20 @@ describe("buildRunFailureView", () => { .toBe("The request to OpenAI timed out"); }); + test("doesn't blame the provider for failures on Fabro's side", () => { + const failure = (message: string) => view(failedRun({ message })); + expect(failure("model request failed (configuration): provider anthropic is disabled in the catalog")).toMatchObject({ + headline: "Fabro couldn't send the request to Anthropic", + nextStep: "Check Anthropic and the step's model under Settings โ†’ Models, then choose Retry from Actions.", + }); + expect(failure("model request failed (model_selection): provider anthropic has no default model")?.headline) + .toBe("Fabro couldn't send the request to Anthropic"); + expect(failure("model request failed (network): provider openai connection refused")?.headline) + .toBe("The request never reached OpenAI"); + expect(failure("model request failed (resource_limit): provider openai response exceeded 16 MiB")?.headline) + .toBe("Fabro stopped the request to OpenAI"); + }); + test("falls back to the provider's status and code", () => { const failure = view( failedRun({ diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts index 3f8080d65..3788db2a2 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -141,6 +141,22 @@ function parseModelRequest(message: string): ModelRequest | null { } function requestAdvice({ kind, status, code }: ModelRequest, message: string, who: string): Advice { + // These kinds fail on Fabro's side, before or without the provider's say. + if (kind === "configuration" || kind === "model_selection") { + return { + headline: `Fabro couldn't send the request to ${who}`, + nextStep: `Check ${who} and the step's model under Settings โ†’ Models, then ${RETRY}.`, + }; + } + if (kind === "network") { + return { + headline: `The request never reached ${who}`, + nextStep: "Check that the Fabro server can reach the provider, then choose Retry from Actions.", + }; + } + if (kind === "middleware" || kind === "resource_limit") { + return { headline: `Fabro stopped the request to ${who}`, nextStep: "The message, under Details, says why." }; + } if ( kind === "quota_exceeded" || status === 402 || From 285a1d06be6084cb719c83c0096a2f7119cdea39 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 13:59:56 -0400 Subject: [PATCH 07/11] 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 { From aed46202b08dc38c11142539a4cfadd99288e7fe Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 14:00:16 -0400 Subject: [PATCH 08/11] Recognize transient infrastructure failures by their reason The server records a failure's category as deterministic or canceled, so checking only the transient_infra category never fired. Check the transient_infra reason as well, the way budget exhaustion already is. Co-Authored-By: Claude Opus 5.5 --- .../app/routes/run-detail/failure.test.ts | 4 ++++ apps/fabro-web/app/routes/run-detail/failure.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 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 7476e598a..d80410436 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.test.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.test.ts @@ -154,6 +154,10 @@ 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: "sandbox connection reset" }, { reason: "transient_infra" }))).toMatchObject({ + headline: "The step hit a temporary infrastructure error", + nextStep: "Choose Retry from Actions in a few minutes.", + }); }); test("says why a dead run stopped, though a dead run records no conclusion", () => { diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts index 7e06c09a4..ac42ebfb2 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -61,7 +61,9 @@ export function buildRunFailureView( const name = (id: string) => providerNames.get(id) ?? id; const advice = reasonAdvice(failure.reason, category) ?? - (request ? requestAdvice(request, message, name(request.provider)) : stepAdvice(message, category, output)); + (request + ? requestAdvice(request, message, name(request.provider)) + : stepAdvice(message, failure.reason, category, output)); return { stage: stage?.replace(/@1$/, "") ?? null, ...advice, @@ -192,7 +194,14 @@ function requestAdvice({ kind, status, code }: ModelRequest, message: string, wh }; } -function stepAdvice(message: string, category: FailureCategory, output: string | null): Advice { +// A transient model request failure keeps requestAdvice's wording, which +// names the provider, so transient infrastructure is checked here. +function stepAdvice( + message: string, + reason: FailureReason, + category: FailureCategory, + output: string | null, +): Advice { const exit = /exited with (?:status|code) (\d+)/i.exec(message); if (exit) { return { @@ -200,7 +209,7 @@ function stepAdvice(message: string, category: FailureCategory, output: string | nextStep: output ? "Its output, under Details, usually says why." : "Open the step's logs for its output.", }; } - if (category === FailureCategory.TRANSIENT_INFRA) { + if (reason === FailureReason.TRANSIENT_INFRA || category === FailureCategory.TRANSIENT_INFRA) { return { headline: "The step hit a temporary infrastructure error", nextStep: "Choose Retry from Actions in a few minutes." }; } return { headline: firstSentence(message), nextStep: "Open the failed step's logs for details." }; From 6e7b75c0df7985a68ed48423b7bd1b8b14398cbc Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 14:00:24 -0400 Subject: [PATCH 09/11] Cap the failure message's height in the banner The output block was capped at max-h-48 but the verbatim message wasn't, so a long stack trace or cause chain could push the tabs out of view in the full-height sandbox and terminal layouts. Co-Authored-By: Claude Opus 5.5 --- apps/fabro-web/app/routes/run-detail/failure-banner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/fabro-web/app/routes/run-detail/failure-banner.tsx b/apps/fabro-web/app/routes/run-detail/failure-banner.tsx index 30ad0b1d2..45d72b513 100644 --- a/apps/fabro-web/app/routes/run-detail/failure-banner.tsx +++ b/apps/fabro-web/app/routes/run-detail/failure-banner.tsx @@ -39,7 +39,7 @@ export function RunFailureBanner({ failure }: { failure: RunFailureView }) {
{failure.provider ? "Provider message" : "Message"}
-
+
{failure.message}
From 7a769525c4e9499eedf4ce1a9f05163423da971c Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 14:00:45 -0400 Subject: [PATCH 10/11] Label the stop step with formatStageLabel The banner stripped a trailing @1 by hand, a second copy of the sidebar's visit-suffix rule. Use formatStageLabel so the banner and the Stages sidebar label a looped step the same way. Co-Authored-By: Claude Opus 5.5 --- .../app/routes/run-detail/failure.test.ts | 5 +++++ apps/fabro-web/app/routes/run-detail/failure.ts | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 2 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 d80410436..ebd9a5733 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,11 @@ describe("buildRunFailureView", () => { expect(failure?.headline).toBe("The steps finished, but publishing the result failed"); }); + test("labels a looped step with its visit, as the Stages sidebar does", () => { + const runStages = stages(["review@1", "succeeded", "2026-10-10T15:20:00Z"], ["review@3", "failed", "2026-10-10T15:30:00Z"]); + expect(view(failedRun({ message: "script exited with status 1" }, { runStages }))?.stage).toBe("review@3"); + }); + 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"], diff --git a/apps/fabro-web/app/routes/run-detail/failure.ts b/apps/fabro-web/app/routes/run-detail/failure.ts index ac42ebfb2..fa757e05c 100644 --- a/apps/fabro-web/app/routes/run-detail/failure.ts +++ b/apps/fabro-web/app/routes/run-detail/failure.ts @@ -5,6 +5,8 @@ import { type RunProjection, } from "@qltysh/fabro-api-client"; +import { formatStageLabel } from "../../lib/stage-sidebar"; + /** Lines of a failed command's output shown on the page. */ const OUTPUT_LINES = 12; /** Fallback headlines are cut to this many characters. */ @@ -65,7 +67,7 @@ export function buildRunFailureView( ? requestAdvice(request, message, name(request.provider)) : stepAdvice(message, failure.reason, category, output)); return { - stage: stage?.replace(/@1$/, "") ?? null, + stage: stageLabel(stage), ...advice, message, provider: request?.provider ?? null, @@ -77,7 +79,7 @@ export function buildRunFailureView( function deadRunView(state: FailureState, lifecycleError: string | null): RunFailureView { const message = lifecycleError?.trim() || "The run ended without recording a reason."; return { - stage: stoppedAt(state.stages)?.replace(/@1$/, "") ?? null, + stage: stageLabel(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, @@ -243,6 +245,14 @@ function stoppedAt(stages: FailureState["stages"]): string | null { return stop?.key ?? null; } +/** A stage key (`node@visit`) as the Stages sidebar labels it. */ +function stageLabel(key: string | null): string | null { + if (!key) return null; + const at = key.lastIndexOf("@"); + if (at <= 0) return key; + return formatStageLabel({ name: key.slice(0, at), visit: Number.parseInt(key.slice(at + 1), 10) || 1 }); +} + function outputTail(tail: ExecOutputTail | null | undefined): string | null { return lastLines([tail?.stderr, tail?.stdout].find((text) => text?.trim())); } From 758ddc532ba341df90e1959fcd6525a39a5e91f0 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Tue, 27 Oct 2026 14:01:35 -0400 Subject: [PATCH 11/11] Fetch provider names only for failed runs, through a shared helper Every run page asked for the provider catalog, though only a failed or dead run's banner uses it. useProviders takes an enabled flag, and the id-to-display-name map settings-models built by hand is now providerNamesById, shared with the run page. Co-Authored-By: Claude Opus 5.5 --- apps/fabro-web/app/lib/model-offerings.ts | 9 ++++++++- apps/fabro-web/app/lib/queries.ts | 5 +++-- apps/fabro-web/app/routes/run-detail.tsx | 6 ++++-- apps/fabro-web/app/routes/settings-models.tsx | 7 ++----- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/fabro-web/app/lib/model-offerings.ts b/apps/fabro-web/app/lib/model-offerings.ts index e2bc94d2d..ad2c056f0 100644 --- a/apps/fabro-web/app/lib/model-offerings.ts +++ b/apps/fabro-web/app/lib/model-offerings.ts @@ -1,4 +1,4 @@ -import type { Model } from "@qltysh/fabro-api-client"; +import type { Model, Provider } from "@qltysh/fabro-api-client"; type ModelOfferingIdentity = Pick; @@ -6,6 +6,13 @@ export function modelOfferingKey(model: ModelOfferingIdentity): string { return `${model.provider}\u0000${model.id}`; } +/** Provider ids to the names the catalog displays, falling back to the id. */ +export function providerNamesById( + providers: readonly Pick[], +): Map { + return new Map(providers.map((p) => [p.id, p.display_name || p.id])); +} + export function modelOfferingTestArgs( model: ModelOfferingIdentity, ): [id: string, provider: string] { diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index fc51316c4..1ca6ec4f3 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -534,9 +534,10 @@ export function useServerSettings() { ); } -export function useProviders() { +/** The provider catalog; `enabled: false` skips the request. */ +export function useProviders({ enabled = true }: { enabled?: boolean } = {}) { return useSWR( - queryKeys.providers.list(), + enabled ? queryKeys.providers.list() : null, () => apiData(() => modelsApi.listProviders()), immutableOptions, ); diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index e910be1ee..dcf2bec17 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -36,6 +36,7 @@ import { import { useRunEvents } from "../lib/run-events"; import { useRunToasts } from "../hooks/use-run-toasts"; import { useProviders, useRun, useRunQuestions, useRunState } from "../lib/queries"; +import { providerNamesById } from "../lib/model-offerings"; import { canApprove, deleteErrorMessage, @@ -151,9 +152,10 @@ export default function RunDetail({ params }: { params: { id: string } }) { useRunEvents(params.id); useRunToasts(params.id); - const providersQuery = useProviders(); + // Provider names only matter to a failed run's banner. + const providersQuery = useProviders({ enabled: statusKind === "failed" || statusKind === "dead" }); const providerNames = useMemo( - () => new Map((providersQuery.data?.data ?? []).map((p) => [p.id, p.display_name || p.id])), + () => providerNamesById(providersQuery.data?.data ?? []), [providersQuery.data], ); const failure = buildRunFailureView(runStateQuery.data, { diff --git a/apps/fabro-web/app/routes/settings-models.tsx b/apps/fabro-web/app/routes/settings-models.tsx index f6e3905df..fde1fe380 100644 --- a/apps/fabro-web/app/routes/settings-models.tsx +++ b/apps/fabro-web/app/routes/settings-models.tsx @@ -31,6 +31,7 @@ import { formatContextWindow, formatTokensPerSecond } from "../lib/format"; import { modelOfferingKey, modelOfferingTestArgs, + providerNamesById, } from "../lib/model-offerings"; import { useDebouncedValue } from "../hooks/effects"; @@ -244,11 +245,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) { const { data, isLoading } = useModels(providerFilter, debouncedSearch); - const providerNameById = useMemo(() => { - const map = new Map(); - for (const p of providers) map.set(p.id, p.display_name || p.id); - return map; - }, [providers]); + const providerNameById = useMemo(() => providerNamesById(providers), [providers]); const rows = useMemo(() => { const all = (data?.data ?? []).filter((m) => m.configured);