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
|
|
@ -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)),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue