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) 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 //#given
const error = { message: "usage limit has been reached for your account" } const error = { message: "usage limit has been reached for your account" }
@@ -180,7 +180,7 @@ describe("model-error-classifier", () => {
const result = shouldRetryError(error) const result = shouldRetryError(error)
//#then //#then
expect(result).toBe(false) expect(result).toBe(true)
}) })
test("treats insufficient credits message as non-retryable STOP error (no error name)", () => { 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 = [ const RETRYABLE_MESSAGE_PATTERNS = [
"rate_limit", "rate_limit",
"rate limit", "rate limit",
"usage_limit_reached",
"usage limit has been reached",
"quota", "quota",
"all credentials for model", "all credentials for model",
"cooling down", "cooling down",
@@ -92,7 +94,6 @@ const RETRYABLE_MESSAGE_PATTERNS = [
const STOP_MESSAGE_PATTERNS = [ const STOP_MESSAGE_PATTERNS = [
"quota will reset after", "quota will reset after",
"quota exceeded", "quota exceeded",
"usage limit has been reached",
"free usage limit", "free usage limit",
"billing limit", "billing limit",
"billing hard 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)) { if (isRecord(dataRaw)) {
const message = dataRaw["message"] const message = dataRaw["message"]
if (typeof message === "string") return 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"] const message = errorRaw["message"]