Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 &&
Expand Down
26 changes: 26 additions & 0 deletions packages/opencode/test/cli/run/run-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
})
11 changes: 7 additions & 4 deletions packages/opencode/test/lib/llm-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type HttpError = {
type: "http-error"
status: number
body: unknown
headers?: Record<string, string>
}

export type Item = Sse | HttpError
Expand Down Expand Up @@ -446,6 +447,7 @@ function fail(item: HttpError) {
return HttpServerResponse.text(JSON.stringify(item.body), {
status: item.status,
contentType: "application/json",
headers: item.headers,
})
}

Expand Down Expand Up @@ -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<string, string>): Item {
return {
type: "http-error",
status,
body,
headers,
}
}

Expand Down Expand Up @@ -621,7 +624,7 @@ namespace TestLLMServer {
readonly toolHang: (name: string, input: unknown) => Effect.Effect<void>
readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect<void>
readonly fail: (message?: unknown) => Effect.Effect<void>
readonly error: (status: number, body: unknown) => Effect.Effect<void>
readonly error: (status: number, body: unknown, headers?: Record<string, string>) => Effect.Effect<void>
readonly hang: Effect.Effect<void>
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
readonly reset: Effect.Effect<void>
Expand Down Expand Up @@ -747,8 +750,8 @@ export class TestLLMServer extends Context.Service<TestLLMServer, TestLLMServer.
fail: Effect.fn("TestLLMServer.fail")(function* (message: unknown = "boom") {
queue(reply().streamError(message).item())
}),
error: Effect.fn("TestLLMServer.error")(function* (status: number, body: unknown) {
queue(httpError(status, body))
error: Effect.fn("TestLLMServer.error")(function* (status: number, body: unknown, headers?: Record<string, string>) {
queue(httpError(status, body, headers))
}),
hang: Effect.gen(function* () {
queue(reply().hang().item())
Expand Down
Loading