cli/mini: fix run failure handling (#35539)

This commit is contained in:
Simon Klee 2026-07-06 12:34:44 +02:00 committed by GitHub
commit 0bba8c780a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 152 additions and 50 deletions

View file

@ -27,6 +27,7 @@
"scripts": {
"build": "bun run script/build.ts",
"dev": "bun run src/index.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {

View file

@ -58,6 +58,6 @@ Effect.logInfo("cli starting", {
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
NodeRuntime.runMain,
)

View file

@ -14,10 +14,11 @@ type CurrentSkill = SkillListOutput["data"][number]
type CurrentProvider = ProviderListOutput["data"][number]
type CurrentModel = ModelListOutput["data"][number]
function location(directory: string) {
function location(directory: string, workspace?: string) {
return {
location: {
directory,
workspace,
},
}
}
@ -98,13 +99,14 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
export async function waitForCatalogReady(input: {
sdk: OpenCodeClient
directory: string
workspace?: string
model: { providerID: string; modelID: string }
timeoutMs?: number
}) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline) {
const models = await input.sdk.model
.list(location(input.directory))
.list(location(input.directory, input.workspace))
.then((result) => result.data)
.catch(() => undefined)
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return

View file

@ -132,8 +132,14 @@ export async function runNonInteractivePrompt(input: Input) {
const consume = async () => {
while (!controller.signal.aborted) {
const next = await stream.next()
if (next.done) throw new Error("Event stream disconnected during prompt execution")
const next = await stream.next().catch((error) => {
if (!emittedError) throw error
return { done: true as const, value: undefined }
})
if (next.done) {
if (emittedError) return
throw new Error("Event stream disconnected during prompt execution")
}
const event = next.value
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
@ -416,7 +422,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
controller.abort()
await completed?.catch(() => {})
if (interrupted) return undefined
if (interrupted || emittedError) return undefined
throw error
})
admission = undefined

View file

@ -8,7 +8,7 @@ import { open } from "node:fs/promises"
import path from "node:path"
import { Daemon } from "../daemon"
import { Standalone } from "../services/standalone"
import { loadRunAgents, waitForCatalogReady, waitForDefaultModel } from "./catalog.shared"
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
import { runNonInteractivePrompt } from "./noninteractive"
import { toolInlineInfo } from "./tool"
import { UI } from "./ui"
@ -49,7 +49,11 @@ type Prepared = {
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
export async function runNonInteractive(input: RunCommandInput) {
export function runNonInteractive(input: RunCommandInput) {
return run(input).catch((error) => reportError(input, error instanceof Error ? error.message : String(error)))
}
async function run(input: RunCommandInput) {
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
const root = process.env.PWD ?? process.cwd()
const directory = input.server ? input.directory : localDirectory(input.directory, root)
@ -78,17 +82,22 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
if (!requestedDirectory) fail("Failed to resolve server directory")
const session = await selectSession(client, requestedDirectory, input)
const cwd = session?.location.directory ?? requestedDirectory
const workspace = session?.location.workspaceID
const explicitModel = parseModel(input.model)
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
const defaultModel =
input.variant && !explicitModel && !sessionModel
? await waitForDefaultModel({ sdk: client, directory: cwd })
!explicitModel && !sessionModel
? await client.model
.default({ location: { directory: cwd, workspace } })
.then((result) =>
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
)
: undefined
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, model })
const available = await client.model.list({ location: { directory: cwd } })
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
}
@ -109,26 +118,21 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
})
}
try {
await runNonInteractivePrompt({
client,
sessionID: selected.id,
message: prepared.message,
files: prepared.files,
agent,
model,
variant: input.variant,
thinking: input.thinking ?? false,
format: input.format,
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
attached: !input.standaloneCommand,
renderTool,
renderToolError,
})
} catch (error) {
UI.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
await runNonInteractivePrompt({
client,
sessionID: selected.id,
message: prepared.message,
files: prepared.files,
agent,
model,
variant: input.variant,
thinking: input.thinking ?? false,
format: input.format,
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false,
attached: !input.standaloneCommand,
renderTool,
renderToolError,
}).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
}
export function mergeInput(message: string | undefined, piped: string | undefined) {
@ -303,6 +307,5 @@ function reportError(input: RunCommandInput, message: string, sessionID?: string
}
function fail(message: string): never {
UI.error(message)
process.exit(1)
throw new Error(message)
}

View file

@ -39,7 +39,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
Effect.gen(function* () {
if (options.mode === "service") {
const service = yield* ServiceConfig.options()
yield* Flock.effect("service-process", {
yield* Flock.effect(path.basename(service.file, ".json") + "-process", {
dir: path.dirname(service.file),
staleMs: 3_000,
timeoutMs: 15_000,

View file

@ -42,7 +42,7 @@ const makeTransport = Effect.fn("cli.standalone.transport")(
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
return { url: ready.url, headers: ServerAuth.headers({ password, username: "opencode" }), pid: proc.pid }
},
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
)

View file

@ -8,7 +8,8 @@ await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* Standalone.transport()
console.log(`${transport.pid} ${transport.url}`)
const response = yield* Effect.promise(() => fetch(new URL("/api/health", transport.url), { headers: transport.headers }))
console.log(`${transport.pid} ${transport.url} ${response.status}`)
return yield* Effect.never
}),
),

View file

@ -1,9 +1,11 @@
import { describe, expect, test } from "bun:test"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "node:path"
import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini"
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: new URL("..", import.meta.url).pathname,
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
stderr: "pipe",
})
@ -61,6 +63,66 @@ describe("mini command", () => {
expect(result.stderr).not.toContain("You must provide a message")
})
test("preserves a run failure exit code", async () => {
let modelRequests = 0
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health")
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
if (url.pathname === "/api/model") {
modelRequests++
return Response.json({
location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [],
})
}
return new Response(undefined, { status: 404 })
},
})
try {
const result = await cli([
"run",
"--server",
server.url.toString(),
"--dir",
process.cwd(),
"--model",
"definitely/missing",
"hi",
])
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("Model unavailable: definitely/missing")
} finally {
server.stop(true)
}
})
test("reports pre-admission errors as JSON", async () => {
const server = Bun.serve({
port: 0,
fetch() {
return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
},
})
try {
const result = await cli(["run", "--format", "json", "--server", server.url.toString(), "hi"])
expect(result.exitCode).toBe(1)
expect(JSON.parse(result.stdout)).toMatchObject({
type: "error",
sessionID: "",
error: { type: "unknown", message: expect.stringContaining("requires") },
})
} finally {
server.stop(true)
}
})
test("uses the shared V2 server option instead of an attach command", async () => {
const result = await cli(["mini", "--help"])

View file

@ -4,18 +4,19 @@ import path from "node:path"
test("standalone server exits when its owner is killed", async () => {
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
cwd: path.join(import.meta.dir, ".."),
env: process.env,
env: { ...process.env, OPENCODE_SERVER_USERNAME: "custom" },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
const [rawPID, url] = line?.split(" ") ?? []
const [rawPID, url, status] = line?.split(" ") ?? []
const pid = Number(rawPID)
try {
expect(pid).toBeGreaterThan(0)
expect(url).toStartWith("http://127.0.0.1:")
expect(status).toBe("200")
expect(running(pid)).toBe(true)
owner.kill("SIGKILL")

View file

@ -104,9 +104,10 @@ export const Info = Schema.Struct({
export type Info = typeof Info.Type
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeHealth = Schema.decodeUnknownEffect(
const decodeHealth = Schema.decodeUnknownOption(
Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }),
)
const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) }))
// A missing or corrupt file means no valid info; callers treat both
// the same (the registering server self-evicts, clients rediscover).
@ -122,7 +123,7 @@ type LocalService = {
readonly transport: Transport
}
const probe = Effect.fnUntraced(function* (info: Info, version?: string) {
const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) {
const headers = info.password === undefined ? undefined : auth(info.password)
const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/health", info.url), {
@ -131,14 +132,20 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string) {
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
if (response === undefined || !response.ok) return undefined
const health = yield* Effect.tryPromise(() => response.json()).pipe(
Effect.flatMap(decodeHealth),
Effect.option,
Effect.map(Option.getOrUndefined),
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
const health = decodeHealth(body)
if (Option.isSome(health)) {
if (health.value.pid !== info.pid) return undefined
if (info.version !== undefined && health.value.version !== info.version) return undefined
if (version !== undefined && health.value.version !== version) return undefined
return { info, transport: { url: info.url, headers } } satisfies LocalService
}
if (
!allowLegacy ||
Option.isNone(decodeLegacyHealth(body)) ||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
)
if (health?.pid !== info.pid) return undefined
if (info.version !== undefined && health.version !== info.version) return undefined
if (version !== undefined && health.version !== version) return undefined
return undefined
return { info, transport: { url: info.url, headers } } satisfies LocalService
})
@ -147,7 +154,7 @@ const probe = Effect.fnUntraced(function* (info: Info, version?: string) {
const find = Effect.fnUntraced(function* (options: Options) {
const info = yield* read(options.file)
if (info === undefined) return undefined
return yield* probe(info)
return yield* probe(info, undefined, true)
})
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.

View file

@ -1,4 +1,6 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { ServiceUnavailableError } from "@opencode-ai/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
@ -17,6 +19,19 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
.handle(
"model.default",
Effect.fn(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.ready.pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () =>
Effect.fail(
new ServiceUnavailableError({
message: "Model catalog initialization timed out",
service: "model.catalog",
}),
),
}),
)
const catalog = yield* Catalog.Service
return yield* response(catalog.model.default())
}),

View file

@ -12,6 +12,10 @@
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/cli#test": {
"dependsOn": ["^build"],
"outputs": []
},
"@opencode-ai/tui#test": {
"dependsOn": ["^build"],
"outputs": []