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:
tw-yshuang
2026-05-07 08:34:52 +08:00
parent 6c51eac8bf
commit fac90d69f8
10 changed files with 573 additions and 102 deletions
+1 -1
View File
@@ -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,
+78 -5
View File
@@ -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 ?? [])
: []
}