cli/mini: fix run failure handling (#35539)
This commit is contained in:
parent
32cf36de9d
commit
0bba8c780a
13 changed files with 152 additions and 50 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/cli#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/tui#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue