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
+27 -7
View File
@@ -87,6 +87,10 @@ import {
resolveSubagentSpawnContext,
type SubagentSpawnContext,
} from "./subagent-spawn-limits"
import {
clearDelegatedChildSessionBootstrap,
registerDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
type OpencodeClient = PluginInput["client"]
@@ -249,6 +253,12 @@ export class BackgroundManager {
}
}
private cleanupDelegatedSessionContext(sessionID: string): void {
clearDelegatedChildSessionBootstrap(sessionID)
this.modelFallbackControllerAccessor?.clearSessionFallbackChain(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
async assertCanSpawn(parentSessionID: string): Promise<SubagentSpawnContext> {
const spawnContext = await resolveSubagentSpawnContext(this.client, parentSessionID, this.directory)
const maxDepth = getMaxSubagentDepth(this.config)
@@ -618,6 +628,14 @@ export class BackgroundManager {
return
}
registerDelegatedChildSessionBootstrap({
sessionID,
promptText: input.prompt,
fallbackChain: input.fallbackChain,
category: input.category,
modelFallbackControllerAccessor: this.modelFallbackControllerAccessor,
})
task.progress = {
toolCalls: 0,
lastUpdate: new Date(),
@@ -778,6 +796,7 @@ The fallback retry session is now created and can be inspected directly.
// Abort the session to prevent infinite polling hang
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
await this.abortSessionWithLogging(sessionID, "launch error cleanup")
this.cleanupDelegatedSessionContext(sessionID)
this.markForNotification(existingTask)
this.enqueueNotificationForParent(existingTask.parentSessionId, () => this.notifyParentSession(existingTask)).catch(err => {
@@ -1417,7 +1436,7 @@ The fallback retry session is now created and can be inspected directly.
}
this.rootDescendantCounts.delete(sessionID)
SessionCategoryRegistry.remove(sessionID)
this.cleanupDelegatedSessionContext(sessionID)
}
if (event.type === "session.status") {
@@ -1521,7 +1540,7 @@ The fallback retry session is now created and can be inspected directly.
}
this.scheduleTaskRemoval(task.id)
if (task.sessionId) {
SessionCategoryRegistry.remove(task.sessionId)
this.cleanupDelegatedSessionContext(task.sessionId)
}
this.markForNotification(task)
@@ -1571,6 +1590,7 @@ The task was re-queued on a fallback model after a retryable failure.
this.clearSessionOutputObserved(previousSessionID)
this.clearSessionTodoObservation(previousSessionID)
subagentSessions.delete(previousSessionID)
this.cleanupDelegatedSessionContext(previousSessionID)
}
return retried
})
@@ -1743,7 +1763,7 @@ The task was re-queued on a fallback model after a retryable failure.
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionId)
if (task.sessionId) {
subagentSessions.delete(task.sessionId)
SessionCategoryRegistry.remove(task.sessionId)
this.cleanupDelegatedSessionContext(task.sessionId)
}
log("[background-agent] Removed completed task from memory:", taskId)
}, TASK_CLEANUP_DELAY_MS)
@@ -1818,7 +1838,7 @@ The task was re-queued on a fallback model after a retryable failure.
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
await this.abortSessionWithLogging(task.sessionId, `task cancellation (${source})`)
SessionCategoryRegistry.remove(task.sessionId)
this.cleanupDelegatedSessionContext(task.sessionId)
}
removeTaskToastTracking(task.id)
@@ -1938,7 +1958,7 @@ The task was re-queued on a fallback model after a retryable failure.
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
await this.abortSessionWithLogging(task.sessionId, `task completion (${source})`)
SessionCategoryRegistry.remove(task.sessionId)
this.cleanupDelegatedSessionContext(task.sessionId)
}
try {
@@ -2236,7 +2256,7 @@ The task was re-queued on a fallback model after a retryable failure.
removeTaskToastTracking(task.id)
this.scheduleTaskRemoval(task.id)
if (task.sessionId) {
SessionCategoryRegistry.remove(task.sessionId)
this.cleanupDelegatedSessionContext(task.sessionId)
}
this.markForNotification(task)
@@ -2414,7 +2434,7 @@ The task was re-queued on a fallback model after a retryable failure.
for (const sessionID of trackedSessionIDs) {
subagentSessions.delete(sessionID)
SessionCategoryRegistry.remove(sessionID)
this.cleanupDelegatedSessionContext(sessionID)
}
this.concurrencyManager.clear()