From 767f5a61485007a0b02a0663161d93b32b001b2f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 17:09:19 +0900 Subject: [PATCH 1/2] fix(model-core): retry OpenAI usage_limit_reached fallbacks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...error-classifier-openai-usage-limit.test.ts | 18 ++++++++++++++++++ .../src/model-error-classifier.test.ts | 4 ++-- .../model-core/src/model-error-classifier.ts | 3 ++- 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 packages/model-core/src/model-error-classifier-openai-usage-limit.test.ts 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", From 6551f3893c00c65331546e3910f7b36412330cff Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 17:09:19 +0900 Subject: [PATCH 2/2] fix(background-agent): retry Atlas subagents on usage limits Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../atlas-subagent-fallback-retry.test.ts | 213 ++++++++++++++++++ .../background-agent/error-classifier.ts | 9 + 2 files changed, 222 insertions(+) create mode 100644 src/features/background-agent/atlas-subagent-fallback-retry.test.ts 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"]