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:
@@ -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)
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
|||||||
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { useKV } from "./kv"
|
import { useKV } from "./kv"
|
||||||
|
import { aggregateFailures } from "./aggregate-failures"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
@@ -391,16 +392,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
.catch(() => emptyConsoleState)
|
.catch(() => emptyConsoleState)
|
||||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||||
const blockingRequests: Promise<unknown>[] = [
|
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
|
||||||
providersPromise,
|
{ name: "config.providers", promise: providersPromise },
|
||||||
providerListPromise,
|
{ name: "provider.list", promise: providerListPromise },
|
||||||
agentsPromise,
|
{ name: "app.agents", promise: agentsPromise },
|
||||||
configPromise,
|
{ name: "config.get", promise: configPromise },
|
||||||
projectPromise,
|
{ name: "project.sync", promise: projectPromise },
|
||||||
...(args.continue ? [sessionListPromise] : []),
|
...(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 () => {
|
.then(async () => {
|
||||||
const providersResponse = providersPromise.then((x) => x.data!)
|
const providersResponse = providersPromise.then((x) => x.data!)
|
||||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user