Skip to content
Merged
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
14 changes: 9 additions & 5 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@
"packages/opencode/src/session/receipt-privacy.ts": "92ba8a92d4ddc0d18249efa5efdf600a32ebc0453d605b8c747cd4bcbd6d19a9",
"packages/opencode/src/session/receipt.ts": "06fd30326424112b8dacd756e4bba5424b3a7a2d10318464a5670aeabb0c4808",
"packages/opencode/src/session/reminders.ts": "abcd602de7f8ab8ab1a0364d30d28f1eb04fe81bf7cd837db9e926ffecf2dc6c",
"packages/opencode/src/session/retry.ts": "36fc688497d53226b97a7157328ae3ad477064e37d4a3e7c69d25e8df75bc303",
"packages/opencode/src/session/rollout.ts": "55518da37d6d4d1298eeab1c7ee208f59b80b7246f3370438de98ad774e4b02e",
"packages/opencode/src/session/run-state.ts": "b6d6270424fc6f1837075ab25847b58a42a774cb0433d8fbc0456c1d71d4cfa2",
"packages/opencode/src/session/session.ts": "d3d8b9e7603aa9e40113a361f5bdbd7a48924250274071f5c07e0c13e1e62d4d",
Expand All @@ -389,7 +390,7 @@
"packages/opencode/src/tool/registry.ts": "0097ab8a7904559d0354d165245caff6bf0e887c99ce267be77fa4ee4b66ec3c",
"packages/opencode/src/tool/shell-output-throttle.ts": "00933a5c28bf4f3026541b1ca226678778177146b5b0b56bb5f189d837be9067",
"packages/opencode/src/tool/shell.ts": "09808bb3865574853d162bfd7e1492a618c230828b95ec3989e9e0afa4acb97c",
"packages/opencode/src/tool/task.ts": "ddeb07a1cf009dcafc1d1d1c86b9d507676a8991db33a4632c8f29ae0e70e654",
"packages/opencode/src/tool/task.ts": "96a41e9ad58a77dca4df1445f562943a0b3a1fdfc1441bb51d66f9117a1afc94",
"packages/opencode/src/tool/truncate.ts": "92c59d83c4a660116d6ecc48e900aeb16bde93962bfae9f520193fda18273379",
"packages/opencode/src/tool/widget-author.ts": "912be8636b162adca8796a501e927019c2449393c14bb5f3ff478b02083b73a9",
"packages/opencode/src/tool/widget-author.txt": "a2572913debbbbd2d4a644dcdf4576accbc7efdcc8217c0fc9f5a011e7a15ec4",
Expand Down Expand Up @@ -460,6 +461,7 @@
"packages/opencode/test/session/prompt.test.ts": "e3d0748edb833640ef284857ae725e4b974843480d4128f56239e3fd1e6d481f",
"packages/opencode/test/session/prose-guard.test.ts": "7239a758cbf27df4c3879fa9ee0374964559c1461773c2f5a85196f64632e6c4",
"packages/opencode/test/session/receipt-privacy.test.ts": "6c8777edc280a061fec1a4d16e2504aec18bdf9cd6dd2cedc7a09ef8fc6c2ca0",
"packages/opencode/test/session/retry.test.ts": "8136de9fed8d493c9703098b5f701674681ecf8a3f823268c039b819753d00e2",
"packages/opencode/test/session/rollout.test.ts": "3153fad45639db647d98d7fd96b3b5c2f0d938fca3a40598a4f88b8f62dda397",
"packages/opencode/test/session/run-state-store.test.ts": "412ad2f9829bc60a3552f3e116783ab5b40d327947cd7ae31235486954be8434",
"packages/opencode/test/session/session.test.ts": "c182f3e5311324175c9ba44a7164737a75ee6d2f22e4a024d66518b5ae91f5f2",
Expand All @@ -472,7 +474,7 @@
"packages/opencode/test/tool/question.test.ts": "75760ba6ed98f2fdfd75945c3b492342dfe91026f99cde86db19cf52444260c2",
"packages/opencode/test/tool/registry-schema.test.ts": "053cb9732299ea6554db007bad873d8d61cd3769aa9514d66c70c3bf0d405b90",
"packages/opencode/test/tool/shell-output-throttle.test.ts": "9fed6f4ea8f7c62941280ab4f973848ea4184274830a153fbea83aae2c028576",
"packages/opencode/test/tool/task.test.ts": "fb0276cc33add808c669a4f43649d189d8f62c75974f3092a5f05e45cf176486",
"packages/opencode/test/tool/task.test.ts": "db9e368fadf22ab0dd216b096834f97176a56126ee5d113f1ba2e772a72fce09",
"packages/opencode/test/tool/truncation.test.ts": "582ef7199b3f294b1c1588fadda8a1c0ea43ca9af0424a97f48747ade1654ad1",
"packages/opencode/test/tool/write.test.ts": "820d36fde2314abbd939ce43ca5bcc4cfa0139d12a4c669716249060d9a955ff",
"packages/schema/src/v1/question.ts": "3ba37742dbd5ef322b7edb42fea0dacf43a1322747dfe997af909bf564bffd35",
Expand Down Expand Up @@ -1011,7 +1013,7 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
"extracted_at": "2026-10-23T12:21:18.499Z",
"extracted_at": "2026-10-23T13:24:11.640Z",
"per_package": {
"packages/app": {
"A": 254,
Expand All @@ -1030,7 +1032,7 @@
},
"packages/opencode": {
"A": 98,
"M": 98,
"M": 100,
"D": 0
},
"packages/schema": {
Expand Down Expand Up @@ -1060,7 +1062,7 @@
}
},
"counts": {
"overlay_total": 989,
"overlay_total": 991,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -1431,6 +1433,7 @@
"packages/opencode/src/session/receipt-privacy.ts": "A",
"packages/opencode/src/session/receipt.ts": "A",
"packages/opencode/src/session/reminders.ts": "M",
"packages/opencode/src/session/retry.ts": "M",
"packages/opencode/src/session/rollout.ts": "A",
"packages/opencode/src/session/run-state.ts": "M",
"packages/opencode/src/session/session.ts": "M",
Expand Down Expand Up @@ -1517,6 +1520,7 @@
"packages/opencode/test/session/prompt.test.ts": "M",
"packages/opencode/test/session/prose-guard.test.ts": "A",
"packages/opencode/test/session/receipt-privacy.test.ts": "A",
"packages/opencode/test/session/retry.test.ts": "M",
"packages/opencode/test/session/rollout.test.ts": "A",
"packages/opencode/test/session/run-state-store.test.ts": "A",
"packages/opencode/test/session/session.test.ts": "M",
Expand Down
235 changes: 235 additions & 0 deletions packages/app-bundle/overlay/packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import type { NamedError } from "@opencode-ai/core/util/error"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"

export type Err = ReturnType<NamedError["toObject"]>

export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
export const GO_UPSELL_URL = "https://opencode.ai/go"
export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {})

export type Retryable = {
message: string
action?: {
reason: RetryReason
provider: string
title: string
message: string
label: string
link?: string
}
}

export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_JITTER_FACTOR = 0.25
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
export const RETRY_MAX_RETRIES = 5
// #1849: rate limits are a shared-quota condition, not a broken request. A
// provider answering `429, retry-after: 1` to every session at once used to
// get five 1-second retries (~6s) and then killed the turn — and with it any
// subagent. Rate-limited turns instead back off exponentially (the header is
// a floor, never the whole wait) and keep retrying for a time budget.
export const RETRY_RATE_LIMIT_BUDGET = 10 * 60_000 // 10 minutes
export const RETRY_RATE_LIMIT_MAX_DELAY = 60_000 // 1 minute between attempts

const RATE_LIMIT_PATTERN = /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests|too_many_requests/i

const RETRYABLE_MESSAGE_PATTERNS = [
/429|500|502|503|504|524/i,
/rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i,
/overloaded|service unavailable|service_unavailable|service-unavailable|internal error|internal_error|internal server error|server error|server_error|server-error|provider returned error|provider_returned_error|provider-returned-error/i,
/terminated|fetch failed|failed to fetch|network[-_\s]error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i,
/^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i,
/try your request again|retry your request|resource exhausted|resource_exhausted/i,
/\btry again (?:later|in\b)|\b(?:currently|temporarily) at capacity\b/i,
]

function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
}

/** The provider's retry hint in ms (retry-after-ms, retry-after seconds or
* HTTP date), or undefined when absent/unparseable/past. */
export function hint(error?: SessionV1.APIError) {
const headers = error?.data.responseHeaders
if (!headers) return undefined
const retryAfterMs = headers["retry-after-ms"]
if (retryAfterMs) {
const parsedMs = Number.parseFloat(retryAfterMs)
if (!Number.isNaN(parsedMs)) return cap(parsedMs)
}
const retryAfter = headers["retry-after"]
if (retryAfter) {
const parsedSeconds = Number.parseFloat(retryAfter)
if (!Number.isNaN(parsedSeconds)) return cap(Math.ceil(parsedSeconds * 1000))
const parsed = Date.parse(retryAfter) - Date.now()
if (!Number.isNaN(parsed) && parsed > 0) return cap(Math.ceil(parsed))
}
return undefined
}

export function delay(attempt: number, error?: SessionV1.APIError, random = Math.random()) {
const hinted = hint(error)
if (hinted !== undefined) return hinted
if (error?.data.responseHeaders) return cap(exponential(attempt, random))
return cap(Math.min(exponential(attempt, random), RETRY_MAX_DELAY_NO_HEADERS))
}

function exponential(attempt: number, random: number) {
const base = RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
return Math.ceil(base + base * RETRY_JITTER_FACTOR * random)
}

/** True for rate-limit failures (HTTP 429 or a rate-limit message/body). */
export function isRateLimit(error: Err) {
if (SessionV1.APIError.isInstance(error)) {
if (error.data.statusCode === 429) return true
return RATE_LIMIT_PATTERN.test(error.data.message) || RATE_LIMIT_PATTERN.test(error.data.responseBody ?? "")
}
const message = isRecord(error.data) ? error.data.message : undefined
return typeof message === "string" && RATE_LIMIT_PATTERN.test(message)
}

/** The wait before a rate-limited retry: exponential with jitter (capped at
* RETRY_RATE_LIMIT_MAX_DELAY), never shorter than the provider's hint. */
export function rateLimitDelay(attempt: number, error?: SessionV1.APIError, random = Math.random()) {
const backoff = Math.min(exponential(attempt, random), RETRY_RATE_LIMIT_MAX_DELAY)
return cap(Math.max(backoff, hint(error) ?? 0))
}

export function retryable(error: Err, provider: string) {
// context overflow errors should not be retried
if (SessionV1.ContextOverflowError.isInstance(error)) return undefined
if (SessionV1.APIError.isInstance(error)) {
const status = error.data.statusCode
// 5xx errors are transient server failures and should always be retried,
// even when the provider SDK doesn't explicitly mark them as retryable.
if (
!error.data.isRetryable &&
!(status !== undefined && status >= 500) &&
!matchesRetryableMessage(error.data.message) &&
!matchesRetryableMessage(error.data.responseBody)
)
return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError")) {
return {
message: GO_UPSELL_MESSAGE,
action: {
reason: "free_tier_limit",
provider,
title: "Free limit reached",
message: "Subscribe to OpenCode Go for reliable access to the best open-source models for $10/month.",
label: "subscribe",
link: GO_UPSELL_URL,
},
}
}
if (error.data.responseBody?.includes("GoUsageLimitError")) {
const body = parseJSON(error.data.responseBody)
const workspace = str(body?.metadata?.workspace)
const limitName = str(body?.metadata?.limitName)
const retryAfter = num(error.data.responseHeaders?.["retry-after"])
const resetIn = iife(() => {
if (retryAfter === undefined) return ""
const seconds = Math.max(0, Math.ceil(retryAfter))
const days = Math.floor(seconds / 86_400)
const hours = Math.floor((seconds % 86_400) / 3_600)
const minutes = Math.ceil((seconds % 3_600) / 60)
const unit = (value: number, name: string) => `${value} ${name}${value === 1 ? "" : "s"}`

if (days > 0) return hours > 0 ? `${unit(days, "day")} ${unit(hours, "hour")}` : unit(days, "day")
if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour")
return minutes > 0 ? unit(minutes, "minute") : "less than a minute"
})

const message = `${limitName ? `${limitName} usage limit` : "Usage limit"} reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance`

const link = `https://opencode.ai/workspace/${workspace}/go`
return {
message: `${message} - ${link}`,
action: {
reason: "account_rate_limit",
provider,
title: "Go limit reached",
message,
label: "open settings",
link,
},
}
}
return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message }
}

const message = isRecord(error.data) ? error.data.message : undefined
if (typeof message !== "string") return undefined
const lower = message.toLowerCase()
if (lower.includes("too_many_requests")) return { message: "Too Many Requests" }
if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" }
if (matchesRetryableMessage(message)) return { message }
return undefined
}

function matchesRetryableMessage(value: unknown) {
return typeof value === "string" && RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(value))
}

function str(value: unknown) {
if (value === undefined || value === null) return ""
return String(value)
}

function num(value: unknown) {
const parsed = Number.parseFloat(str(value))
if (Number.isNaN(parsed)) return undefined
return parsed
}

function parseJSON(value: unknown) {
return iife(() => {
try {
if (typeof value !== "string") return undefined
return JSON.parse(value)
} catch {
return undefined
}
})
}

export function policy(opts: {
provider: string
parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
}) {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
const api = SessionV1.APIError.isInstance(error) ? error : undefined
// usage-limit upsells (free tier / Go) reset on hours-scale clocks; they
// keep the attempt cap. Plain rate limits get the time budget.
const rateLimited = !retry.action && isRateLimit(error)
const wait = rateLimited ? rateLimitDelay(meta.attempt, api) : delay(meta.attempt, api)
if (rateLimited) {
if (meta.elapsed >= RETRY_RATE_LIMIT_BUDGET) return Cause.done(meta.attempt)
} else if (meta.attempt > RETRY_MAX_RETRIES) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
yield* opts.set({
attempt: meta.attempt,
message: retry.message,
action: retry.action,
next: now + wait,
})
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
})
}),
)
}

export * as SessionRetry from "./retry"
23 changes: 22 additions & 1 deletion packages/app-bundle/overlay/packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ function renderOutput(input: {
].join("\n")
}

export function subagentFailure(sessionID: SessionID, error: { name: string; data?: unknown }, partial: string) {
const data = error.data as { message?: unknown } | undefined
const detail = typeof data?.message === "string" && data.message ? `${error.name}: ${data.message}` : error.name
return [
`The subagent stopped before finishing (${detail}).`,
`Its work so far is preserved in session ${sessionID}.`,
`To continue it, call the task tool again with task_id="${sessionID}" and a prompt such as "continue where you left off" — do not start a new task for the same work.`,
...(partial ? ["", "Last output before it stopped:", partial] : []),
].join("\n")
}

export const TaskTool = Tool.define(
id,
Effect.gen(function* () {
Expand Down Expand Up @@ -211,7 +222,17 @@ export const TaskTool = Tool.define(
agent: next.name,
parts,
})
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
const text = result.parts.findLast((item) => item.type === "text")?.text ?? ""
// #1849: a child whose final turn errored (a provider 429 that outlived
// its retries, an auth failure, ...) used to come back as an EMPTY
// success — the parent saw "completed" with no result and started a
// fresh subagent, discarding all the child's work. Report it as a
// failure that names the resumable session instead.
const error = result.info.role === "assistant" ? result.info.error : undefined
if (error && error.name !== "MessageAbortedError") {
return yield* Effect.fail(new Error(subagentFailure(nextSession.id, error, text)))
}
return text
})

const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* (
Expand Down
Loading
Loading