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 -72
View File
@@ -6,64 +6,11 @@ import { buildTaskPrompt } from "./prompt-builder"
import { publishToolMetadata } from "../../features/tool-metadata-store"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
import { resolveMetadataModel } from "./resolve-metadata-model"
function registerBackgroundSessionContext(args: {
sessionId: string
fallbackChain?: FallbackEntry[]
category?: string
modelFallbackControllerAccessor?: ExecutorContext["modelFallbackControllerAccessor"]
}): void {
args.modelFallbackControllerAccessor?.setSessionFallbackChain(args.sessionId, args.fallbackChain)
if (args.category) {
SessionCategoryRegistry.register(args.sessionId, args.category)
}
}
function continueSessionSetup(args: {
taskID: string
manager: ExecutorContext["manager"]
timing: ReturnType<typeof getTimingConfig>
fallbackChain?: FallbackEntry[]
category?: string
modelFallbackControllerAccessor?: ExecutorContext["modelFallbackControllerAccessor"]
}): void {
if (!args.fallbackChain && !args.category) {
return
}
void (async () => {
const waitStart = Date.now()
while (Date.now() - waitStart < args.timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
await new Promise(resolve => setTimeout(resolve, args.timing.WAIT_FOR_SESSION_INTERVAL_MS))
const updated = args.manager.getTask(args.taskID)
if (!updated) {
return
}
if (updated.status === "error" || updated.status === "cancelled" || updated.status === "interrupt") {
return
}
const sessionId = updated.sessionId
if (!sessionId) {
continue
}
registerBackgroundSessionContext({
sessionId,
fallbackChain: args.fallbackChain,
category: args.category,
modelFallbackControllerAccessor: args.modelFallbackControllerAccessor,
})
return
}
})()
}
async function waitForBackgroundSessionStart(args: {
taskId: string
initialSessionId?: string
@@ -141,16 +88,7 @@ export async function executeBackgroundTask(
manager,
timing,
abortSignal: ctx.abort,
onAbort: () => {
continueSessionSetup({
taskID: task.id,
manager,
timing,
fallbackChain,
category: args.category,
modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor,
})
},
onAbort: () => {},
})
const updatedTask = typeof manager.getTask === "function"
@@ -160,15 +98,6 @@ export async function executeBackgroundTask(
return `Task failed to start (status: ${updatedTask.status}).\n\nTask ID: ${task.id}`
}
if (sessionId) {
registerBackgroundSessionContext({
sessionId,
fallbackChain,
category: args.category,
modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor,
})
}
const resolvedModel = resolveMetadataModel(categoryModel, parentContext.model)
const metadata = {
prompt: args.prompt,