diff --git a/packages/model-core/src/model-error-classifier-openai-usage-limit.test.ts b/packages/model-core/src/model-error-classifier-openai-usage-limit.test.ts
new file mode 100644
index 000000000..0d6f2dc7b
--- /dev/null
+++ b/packages/model-core/src/model-error-classifier-openai-usage-limit.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from "bun:test"
+import { shouldRetryError } from "./model-error-classifier"
+
+describe("model-error-classifier OpenAI usage_limit_reached", () => {
+ test("treats OpenAI usage_limit_reached response bodies as retryable provider exhaustion", () => {
+ //#given
+ const error = {
+ name: "AI_APICallError",
+ message: '{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}',
+ }
+
+ //#when
+ const result = shouldRetryError(error)
+
+ //#then
+ expect(result).toBe(true)
+ })
+})
diff --git a/packages/model-core/src/model-error-classifier.test.ts b/packages/model-core/src/model-error-classifier.test.ts
index 172899e64..43096fc5a 100644
--- a/packages/model-core/src/model-error-classifier.test.ts
+++ b/packages/model-core/src/model-error-classifier.test.ts
@@ -172,7 +172,7 @@ describe("model-error-classifier", () => {
expect(result).toBe(false)
})
- test("treats usage limit reached message as non-retryable STOP error (no error name)", () => {
+ test("treats provider usage limit reached message as retryable fallback signal", () => {
//#given
const error = { message: "usage limit has been reached for your account" }
@@ -180,7 +180,7 @@ describe("model-error-classifier", () => {
const result = shouldRetryError(error)
//#then
- expect(result).toBe(false)
+ expect(result).toBe(true)
})
test("treats insufficient credits message as non-retryable STOP error (no error name)", () => {
diff --git a/packages/model-core/src/model-error-classifier.ts b/packages/model-core/src/model-error-classifier.ts
index 0786cd9fe..f9f5b3e5e 100644
--- a/packages/model-core/src/model-error-classifier.ts
+++ b/packages/model-core/src/model-error-classifier.ts
@@ -40,6 +40,8 @@ const NON_RETRYABLE_ERROR_NAMES = new Set([
const RETRYABLE_MESSAGE_PATTERNS = [
"rate_limit",
"rate limit",
+ "usage_limit_reached",
+ "usage limit has been reached",
"quota",
"all credentials for model",
"cooling down",
@@ -92,7 +94,6 @@ const RETRYABLE_MESSAGE_PATTERNS = [
const STOP_MESSAGE_PATTERNS = [
"quota will reset after",
"quota exceeded",
- "usage limit has been reached",
"free usage limit",
"billing limit",
"billing hard limit",
diff --git a/src/features/background-agent/atlas-subagent-fallback-retry.test.ts b/src/features/background-agent/atlas-subagent-fallback-retry.test.ts
new file mode 100644
index 000000000..4c67ee6b4
--- /dev/null
+++ b/src/features/background-agent/atlas-subagent-fallback-retry.test.ts
@@ -0,0 +1,213 @@
+///
+
+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
+import type { PluginInput } from "@opencode-ai/plugin"
+import { _resetMemCacheForTesting as resetConnectedProvidersCacheForTesting } from "../../shared/connected-providers-cache"
+import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate"
+import {
+ getSessionAgent,
+ _resetForTesting as resetClaudeCodeSessionState,
+ subagentSessions,
+} from "../claude-code-session-state"
+import { BackgroundManager } from "./manager"
+import { clearBackgroundTaskRegistryForTesting } from "./task-registry"
+
+type SessionGetArgs = { readonly path: { readonly id: string } }
+type SessionCreateArgs = {
+ readonly body?: {
+ readonly parentID?: string
+ readonly model?: { readonly providerID?: string; readonly id?: string; readonly variant?: string }
+ }
+}
+type PromptCall = { readonly path: { readonly id: string }; readonly body?: unknown }
+
+const originalXdgCacheHome = process.env.XDG_CACHE_HOME
+const testDirectory = "/tmp/omo-atlas-fallback-test"
+let cacheCounter = 0
+
+beforeEach(() => {
+ process.env.XDG_CACHE_HOME = `${testDirectory}/cache-${cacheCounter}`
+ cacheCounter += 1
+ resetConnectedProvidersCacheForTesting()
+ resetClaudeCodeSessionState()
+})
+
+afterEach(() => {
+ if (originalXdgCacheHome === undefined) {
+ delete process.env.XDG_CACHE_HOME
+ } else {
+ process.env.XDG_CACHE_HOME = originalXdgCacheHome
+ }
+ resetConnectedProvidersCacheForTesting()
+ resetClaudeCodeSessionState()
+ clearBackgroundTaskRegistryForTesting()
+ releaseAllPromptAsyncReservationsForTesting()
+})
+
+function createPluginInput(client: unknown, directory: string): PluginInput {
+ return { client, directory } as PluginInput
+}
+
+async function flushAsyncWork(cycles = 30): Promise {
+ for (let index = 0; index < cycles; index++) {
+ await Promise.resolve()
+ }
+}
+
+function createAtlasHarness(): {
+ readonly manager: BackgroundManager
+ readonly createdSessions: Array<{ readonly id: string; readonly body: SessionCreateArgs["body"] }>
+ readonly promptCalls: PromptCall[]
+ readonly markSessionMissing: (sessionID: string) => void
+} {
+ const directory = testDirectory
+ const sessionAlive = new Map([["atlas-parent", true]])
+ const createdSessions: Array<{ readonly id: string; readonly body: SessionCreateArgs["body"] }> = []
+ const promptCalls: PromptCall[] = []
+ const sessionIDs = ["ses_primary", "ses_fallback"]
+
+ const client = {
+ session: {
+ get: async ({ path }: SessionGetArgs) => {
+ if (path.id === "atlas-parent") {
+ return { data: { id: path.id, directory, parentID: undefined } }
+ }
+ if (sessionAlive.get(path.id)) {
+ return { data: { id: path.id, directory, parentID: "atlas-parent" } }
+ }
+ return { error: { status: 404, message: `session ${path.id} not found` } }
+ },
+ create: async (args: SessionCreateArgs) => {
+ const id = sessionIDs[createdSessions.length] ?? `ses_extra_${createdSessions.length}`
+ createdSessions.push({ id, body: args.body })
+ sessionAlive.set(id, true)
+ return { data: { id } }
+ },
+ promptAsync: async (args: PromptCall) => {
+ promptCalls.push(args)
+ return {}
+ },
+ abort: async ({ path }: SessionGetArgs) => {
+ sessionAlive.set(path.id, false)
+ return {}
+ },
+ },
+ }
+ const manager = new BackgroundManager({ pluginContext: createPluginInput(client, directory) })
+
+ return {
+ manager,
+ createdSessions,
+ promptCalls,
+ markSessionMissing: (sessionID: string) => sessionAlive.set(sessionID, false),
+ }
+}
+
+async function launchAtlasOracleSubagent(manager: BackgroundManager): Promise {
+ const task = await manager.launch({
+ description: "Atlas oracle subagent",
+ prompt: "Investigate fallback behavior",
+ agent: "oracle",
+ parentSessionId: "atlas-parent",
+ parentMessageId: "atlas-message",
+ parentAgent: "atlas",
+ model: { providerID: "openai", modelID: "gpt-5.5", variant: "high" },
+ fallbackChain: [
+ { providers: ["github-copilot"], model: "claude-sonnet-4.6", variant: "high" },
+ ],
+ })
+ await flushAsyncWork()
+ return task.id
+}
+
+function emitUsageLimitError(manager: BackgroundManager, sessionID: string): void {
+ manager.handleEvent({
+ type: "session.error",
+ properties: {
+ sessionID,
+ error: {
+ name: "AI_APICallError",
+ data: {
+ error: {
+ type: "usage_limit_reached",
+ message: "The usage limit has been reached",
+ },
+ },
+ },
+ },
+ })
+}
+
+describe("Atlas-spawned subagent runtime fallback", () => {
+ test("retries oracle subagent on OpenAI usage_limit_reached and registers the fallback session", async () => {
+ //#given
+ const { manager, createdSessions, promptCalls } = createAtlasHarness()
+ const taskID = await launchAtlasOracleSubagent(manager)
+
+ //#when
+ emitUsageLimitError(manager, "ses_primary")
+ await flushAsyncWork(60)
+
+ //#then
+ const task = manager.getTask(taskID)
+ expect(task?.status).toBe("running")
+ expect(task?.sessionId).toBe("ses_fallback")
+ expect(task?.model).toEqual({ providerID: "github-copilot", modelID: "claude-sonnet-4.6", variant: "high" })
+ expect(task?.attemptCount).toBe(1)
+ expect(createdSessions).toHaveLength(2)
+ expect(createdSessions[1]?.body?.model).toEqual({ providerID: "github-copilot", id: "claude-sonnet-4.6", variant: "high" })
+ expect(promptCalls).toHaveLength(2)
+ expect(subagentSessions.has("ses_primary")).toBe(false)
+ expect(subagentSessions.has("ses_fallback")).toBe(true)
+ expect(getSessionAgent("ses_fallback")).toBe("oracle")
+
+ manager.shutdown()
+ })
+
+ test("surfaces non-retryable oracle subagent errors without creating a fallback session", async () => {
+ //#given
+ const { manager, createdSessions, markSessionMissing } = createAtlasHarness()
+ const taskID = await launchAtlasOracleSubagent(manager)
+ markSessionMissing("ses_primary")
+
+ //#when
+ manager.handleEvent({
+ type: "session.error",
+ properties: {
+ sessionID: "ses_primary",
+ error: { name: "PermissionDeniedError", data: { message: "permission denied" } },
+ },
+ })
+ await flushAsyncWork(60)
+
+ //#then
+ const task = manager.getTask(taskID)
+ expect(task?.status).toBe("error")
+ expect(task?.error).toBe("permission denied")
+ expect(createdSessions).toHaveLength(1)
+
+ manager.shutdown()
+ })
+
+ test("marks oracle subagent errored when usage_limit_reached exhausts all fallbacks", async () => {
+ //#given
+ const { manager, createdSessions, markSessionMissing } = createAtlasHarness()
+ const taskID = await launchAtlasOracleSubagent(manager)
+ emitUsageLimitError(manager, "ses_primary")
+ await flushAsyncWork(60)
+ markSessionMissing("ses_fallback")
+
+ //#when
+ emitUsageLimitError(manager, "ses_fallback")
+ await flushAsyncWork(60)
+
+ //#then
+ const task = manager.getTask(taskID)
+ expect(task?.status).toBe("error")
+ expect(task?.error).toBe("The usage limit has been reached")
+ expect(task?.attemptCount).toBe(1)
+ expect(createdSessions).toHaveLength(2)
+
+ manager.shutdown()
+ })
+})
diff --git a/src/features/background-agent/error-classifier.ts b/src/features/background-agent/error-classifier.ts
index 7fbfd031b..a0e3b0c01 100644
--- a/src/features/background-agent/error-classifier.ts
+++ b/src/features/background-agent/error-classifier.ts
@@ -104,6 +104,15 @@ export function getSessionErrorMessage(properties: EventPropertiesLike): string
if (isRecord(dataRaw)) {
const message = dataRaw["message"]
if (typeof message === "string") return message
+
+ const nestedError = dataRaw["error"]
+ if (isRecord(nestedError)) {
+ const nestedMessage = nestedError["message"]
+ if (typeof nestedMessage === "string") return nestedMessage
+
+ const nestedType = nestedError["type"]
+ if (typeof nestedType === "string") return nestedType
+ }
}
const message = errorRaw["message"]