diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..a8c88a7dd7f7 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -87,6 +87,13 @@ function formatRunError(error: unknown) { return FormatError(error) ?? FormatUnknownError(error) } +// Retry reasons that will not clear on their own within a run. The session retry +// schedule honors the provider's retry-after for these, which for an exhausted +// quota is measured in days, so a non-interactive run reports and stops instead +// of sleeping through it. Every other retryable failure is transient and keeps +// its existing wait-and-retry behavior. +const NON_TRANSIENT_RETRY_REASONS = new Set(["free_tier_limit", "account_rate_limit"]) + async function tool(part: ToolPart) { try { const { toolInlineInfo } = await import("./run/tool") @@ -785,6 +792,28 @@ export const RunCommand = effectCmd({ UI.error(err) } + // Retries are invisible here otherwise: the session stays busy, no + // session.error is published, and idle only arrives once the retry + // schedule finishes. A transient retry (429/5xx, short retry-after) + // is worth waiting out, but quota exhaustion carries a retry-after + // measured in days, which an unattended run must not sleep through. + if (event.type === "session.status" && event.properties.sessionID === sessionID) { + const status = event.properties.status + if (status.type === "retry") { + if (!emit("retry", { attempt: status.attempt, message: status.message, next: status.next })) { + UI.error(`${status.message} (attempt ${status.attempt})`) + } + if (NON_TRANSIENT_RETRY_REASONS.has(status.action?.reason ?? "")) { + error = error ? error + EOL + status.message : status.message + // The schedule is already sleeping on the provider's retry-after. + // Abort so that pending wait cannot hold the process open after + // this loop stops reading events. + await client.session.abort({ sessionID }).catch(() => {}) + break + } + } + } + if ( event.type === "session.status" && event.properties.sessionID === sessionID && diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e2723c..118e7e1e2ccb 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -328,4 +328,30 @@ describe("opencode run (non-interactive subprocess)", () => { }), 30_000, ) + + // An exhausted quota answers with a retry-after measured in days, which the + // session retry schedule honors. A non-interactive run has nowhere to show a + // pending retry, so it must report the limit and stop rather than sleep on it. + cliIt.concurrent( + "reports and exits non-zero instead of waiting out an exhausted quota", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.error( + 429, + { + name: "GoUsageLimitError", + message: "Monthly usage limit reached.", + metadata: { workspace: "wrk_test", limitName: "Monthly" }, + }, + { "retry-after": "345600" }, + ) + + const result = yield* opencode.run("say hi") + + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain("usage limit reached") + expect(result.durationMs).toBeLessThan(30_000) + }), + 60_000, + ) }) diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 245acc7280f5..9503ab8e5fc7 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -46,6 +46,7 @@ type HttpError = { type: "http-error" status: number body: unknown + headers?: Record } export type Item = Sse | HttpError @@ -446,6 +447,7 @@ function fail(item: HttpError) { return HttpServerResponse.text(JSON.stringify(item.body), { status: item.status, contentType: "application/json", + headers: item.headers, }) } @@ -565,11 +567,12 @@ export function reply() { return new Reply() } -export function httpError(status: number, body: unknown): Item { +export function httpError(status: number, body: unknown, headers?: Record): Item { return { type: "http-error", status, body, + headers, } } @@ -621,7 +624,7 @@ namespace TestLLMServer { readonly toolHang: (name: string, input: unknown) => Effect.Effect readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect readonly fail: (message?: unknown) => Effect.Effect - readonly error: (status: number, body: unknown) => Effect.Effect + readonly error: (status: number, body: unknown, headers?: Record) => Effect.Effect readonly hang: Effect.Effect readonly hold: (value: string, wait: PromiseLike) => Effect.Effect readonly reset: Effect.Effect @@ -747,8 +750,8 @@ export class TestLLMServer extends Context.Service) { + queue(httpError(status, body, headers)) }), hang: Effect.gen(function* () { queue(reply().hang().item())