fix(tui): aggregate bootstrap request failures

TUI bootstrap now reports all parallel fetch failures together instead of losing sibling failures after the first rejection.
This commit is contained in:
Kit Langton
2026-05-09 18:46:52 -04:00
committed by GitHub
parent 11363170ca
commit 29b5b24787
3 changed files with 111 additions and 8 deletions

View File

@@ -0,0 +1,34 @@
/**
* Aggregate Promise.allSettled results into a single Error that names every
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
* boundaries so a single 4xx doesn't drown its parallel siblings as
* unhandled rejections — every failure surfaces in one labeled message.
*/
export type LabeledSettled = {
name: string
result: PromiseSettledResult<unknown>
}
export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
const failed = labeled.filter(
(x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
)
if (failed.length === 0) return null
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
const err = new Error(summary)
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
return err
}
function reasonMessage(reason: unknown): string {
if (reason instanceof Error) return reason.message
if (typeof reason === "string") return reason
if (reason && typeof reason === "object") {
const obj = reason as { message?: unknown; name?: unknown }
if (typeof obj.message === "string") return obj.message
if (typeof obj.name === "string") return obj.name
}
return String(reason)
}

View File

@@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import path from "path"
import { useKV } from "./kv"
import { aggregateFailures } from "./aggregate-failures"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -391,16 +392,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
{ name: "config.providers", promise: providersPromise },
{ name: "provider.list", promise: providerListPromise },
{ name: "app.agents", promise: agentsPromise },
{ name: "config.get", promise: configPromise },
{ name: "project.sync", promise: projectPromise },
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
]
await Promise.all(blockingRequests)
await Promise.allSettled(blockingRequests.map((r) => r.promise))
.then((settled) => {
// Surface every failed endpoint in one labeled message instead of
// letting the first rejection drown its siblings as unhandled
// rejections.
const failure = aggregateFailures(
blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })),
)
if (failure) throw failure
})
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)

View File

@@ -0,0 +1,59 @@
/**
* Regression test for the TUI bootstrap aggregation helper. Replaces the
* pre-fix Promise.all behavior where the first rejection drowned every
* sibling endpoint's failure as an unhandled rejection.
*/
import { describe, expect, test } from "bun:test"
import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures"
describe("aggregateFailures", () => {
test("returns null when every result is fulfilled", () => {
expect(
aggregateFailures([
{ name: "config", result: { status: "fulfilled", value: 1 } },
{ name: "providers", result: { status: "fulfilled", value: 2 } },
]),
).toBeNull()
})
test("names the failed endpoint when one rejects", () => {
const err = aggregateFailures([
{ name: "config", result: { status: "fulfilled", value: 1 } },
{
name: "providers",
result: { status: "rejected", reason: new Error("Service unavailable") },
},
])
expect(err).toBeInstanceOf(Error)
expect(err!.message).toContain("1 of 2")
expect(err!.message).toContain("providers: Service unavailable")
})
test("names every failed endpoint when multiple reject", () => {
const err = aggregateFailures([
{ name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } },
{ name: "providers", result: { status: "fulfilled", value: 1 } },
{ name: "agents", result: { status: "rejected", reason: { message: "boom" } } },
])
expect(err).toBeInstanceOf(Error)
expect(err!.message).toContain("2 of 3")
expect(err!.message).toContain("config: 400 Bad Request")
expect(err!.message).toContain("agents: boom")
})
test("attaches structured failure list under .cause", () => {
const reason = new Error("nope")
const err = aggregateFailures([
{ name: "providers", result: { status: "rejected", reason } },
])
const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> }
expect(cause.failures).toEqual([{ name: "providers", reason }])
})
test("falls back to String() for opaque reasons", () => {
const err = aggregateFailures([
{ name: "x", result: { status: "rejected", reason: 42 } },
])
expect(err!.message).toContain("x: 42")
})
})