Merge pull request #4453 from code-yeongyu/fix-4419-atlas-subagent-fallback

fix(background-agent): atlas subagent fallback retry on retryable model errors (#4419)
This commit is contained in:
YeonGyu-Kim
2026-05-25 17:33:03 +09:00
committed by GitHub
5 changed files with 244 additions and 3 deletions
@@ -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)
})
})
@@ -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)", () => {
@@ -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",
@@ -0,0 +1,213 @@
/// <reference types="bun-types" />
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<void> {
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<string, boolean>([["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<string> {
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()
})
})
@@ -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"]