fix(delegate-task): harden child-session fallback bootstrap and cleanup
Capture delegated child-session retry context before the first prompt so fallback recovery still works when session history is empty. Align background and sync launch paths around the same bootstrap contract, clear session-scoped fallback state on every terminal path, and lock the behavior with regression coverage for first-prompt retries, exhaustion, isolation, and cleanup. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -125,7 +125,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const retryParts = getLastUserRetryParts(messagesResp)
|
||||
const retryParts = getLastUserRetryParts(messagesResp, sessionID)
|
||||
if (retryParts.length > 0) {
|
||||
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
|
||||
sessionID,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
|
||||
import * as loggerModule from "../../shared/logger"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import {
|
||||
clearAllDelegatedChildSessionBootstrap,
|
||||
registerDelegatedChildSessionBootstrap,
|
||||
} from "../../shared/delegated-child-session-bootstrap"
|
||||
|
||||
type RuntimeFallbackModule = typeof import("./hook")
|
||||
|
||||
@@ -15,6 +19,7 @@ describe("runtime-fallback", () => {
|
||||
logCalls = []
|
||||
toastCalls = []
|
||||
SessionCategoryRegistry.clear()
|
||||
clearAllDelegatedChildSessionBootstrap()
|
||||
|
||||
const cacheBuster = `${Date.now()}-${Math.random()}`
|
||||
|
||||
@@ -31,6 +36,7 @@ describe("runtime-fallback", () => {
|
||||
|
||||
afterEach(() => {
|
||||
SessionCategoryRegistry.clear()
|
||||
clearAllDelegatedChildSessionBootstrap()
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
@@ -2720,9 +2726,18 @@ describe("runtime-fallback", () => {
|
||||
expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("pendingFallbackModel advances chain on subsequent error even when persisted", async () => {
|
||||
test("delegated child-session empty-history fallback retries with captured bootstrap prompt", async () => {
|
||||
//#given
|
||||
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
|
||||
const promptCalls: Array<Record<string, unknown>> = []
|
||||
const hook = createRuntimeFallbackHook(createMockPluginInput({
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async (args) => {
|
||||
promptCalls.push(args as Record<string, unknown>)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}), {
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
pluginConfig: {
|
||||
git_master: {
|
||||
@@ -2737,8 +2752,12 @@ describe("runtime-fallback", () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
const sessionID = "test-race-pending-persists"
|
||||
SessionCategoryRegistry.register(sessionID, "test")
|
||||
const sessionID = "test-delegated-empty-history-pending-persists"
|
||||
registerDelegatedChildSessionBootstrap({
|
||||
sessionID,
|
||||
promptText: "delegated retry payload",
|
||||
category: "test",
|
||||
})
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
@@ -2754,8 +2773,13 @@ describe("runtime-fallback", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
expect(promptCalls[0]?.body).toMatchObject({
|
||||
model: { providerID: "provider-a", modelID: "model-a" },
|
||||
parts: [{ type: "text", text: expect.stringContaining("delegated retry payload") }],
|
||||
})
|
||||
const autoRetryLog = logCalls.find((call) => call.msg.includes("No user message found for auto-retry"))
|
||||
expect(autoRetryLog).toBeDefined()
|
||||
expect(autoRetryLog).toBeUndefined()
|
||||
|
||||
//#when - second error fires after retry completed (retryInFlight cleared)
|
||||
await hook.event({
|
||||
@@ -2769,5 +2793,54 @@ describe("runtime-fallback", () => {
|
||||
const fallbackLogs = logCalls.filter((call) => call.msg.includes("Preparing fallback"))
|
||||
expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("empty-history fallback without delegated bootstrap still does not invent retry payloads", async () => {
|
||||
//#given
|
||||
const promptCalls: Array<Record<string, unknown>> = []
|
||||
const hook = createRuntimeFallbackHook(createMockPluginInput({
|
||||
session: {
|
||||
messages: async () => ({ data: [] }),
|
||||
promptAsync: async (args) => {
|
||||
promptCalls.push(args as Record<string, unknown>)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}), {
|
||||
config: createMockConfig({ notify_on_fallback: false }),
|
||||
pluginConfig: {
|
||||
git_master: {
|
||||
commit_footer: true,
|
||||
include_co_authored_by: true,
|
||||
git_env_prefix: "GIT_MASTER=1",
|
||||
},
|
||||
categories: {
|
||||
test: {
|
||||
fallback_models: ["provider-a/model-a", "provider-b/model-b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const sessionID = "test-empty-history-without-bootstrap"
|
||||
SessionCategoryRegistry.register(sessionID, "test")
|
||||
|
||||
//#when
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.created",
|
||||
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
|
||||
},
|
||||
})
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429, message: "Rate limit" } },
|
||||
},
|
||||
})
|
||||
|
||||
//#then
|
||||
const autoRetryLog = logCalls.find((call) => call.msg.includes("No user message found for auto-retry"))
|
||||
expect(autoRetryLog).toBeDefined()
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { extractSessionMessages } from "./session-messages"
|
||||
import { getDelegatedChildSessionBootstrap } from "../../shared/delegated-child-session-bootstrap"
|
||||
|
||||
export function getLastUserRetryParts(
|
||||
messagesResponse: unknown,
|
||||
sessionID?: string,
|
||||
): Array<{ type: "text"; text: string }> {
|
||||
const messages = extractSessionMessages(messagesResponse)
|
||||
const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop()
|
||||
@@ -9,7 +11,7 @@ export function getLastUserRetryParts(
|
||||
lastUserMessage?.parts
|
||||
?? (lastUserMessage?.info?.parts as Array<{ type?: string; text?: string }> | undefined)
|
||||
|
||||
return (lastUserParts ?? [])
|
||||
const retryParts = (lastUserParts ?? [])
|
||||
.filter(
|
||||
(part): part is { type: "text"; text: string } =>
|
||||
part.type === "text"
|
||||
@@ -17,4 +19,12 @@ export function getLastUserRetryParts(
|
||||
&& part.text.length > 0,
|
||||
)
|
||||
.map((part) => ({ type: "text" as const, text: part.text }))
|
||||
|
||||
if (retryParts.length > 0) {
|
||||
return retryParts
|
||||
}
|
||||
|
||||
return sessionID
|
||||
? (getDelegatedChildSessionBootstrap(sessionID)?.retryParts ?? [])
|
||||
: []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user