fix(background-agent): keep delegated skill, permission, and child agent across retries

Three coupled gaps surfaced after the initial spawn fix:

1. fallback-retry-handler dropped task.skillContent and
   task.sessionPermission when rebuilding LaunchInput, so the retried
   background task lost the delegated system prompt and question-deny
   permission rule.
2. manager.startTask never bound the child sessionID to the resolved
   agent via setSessionAgent, leaving runtime fallback and other hooks
   with no idea which agent owned the new child session.
3. The fallback-to-general path in spawner.ts rebuilt the prompt body
   without going through buildFallbackBody, so bootstrap state, session
   tools, and session agent updates drifted apart.

Persist skillContent and sessionPermission on BackgroundTask, bind
setSessionAgent/updateSessionAgent at session creation and on fallback,
and route the FALLBACK_AGENT retry through buildFallbackBody so the
prompt body, bootstrap tools, and session registries all agree.
This commit is contained in:
YeonGyu-Kim
2026-05-17 00:08:44 +09:00
parent ba648685d4
commit 097d7dc547
7 changed files with 230 additions and 74 deletions
@@ -1,10 +1,12 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { tryFallbackRetry, type FallbackRetryHandlerDeps } from "./fallback-retry-handler"
import type { FallbackEntry } from "../../shared/model-requirements"
import type { ProviderModelsCache } from "../../shared/connected-providers-cache"
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
const sharedLogMock = mock(() => {})
const readConnectedProvidersCacheMock = mock(() => null)
const readProviderModelsCacheMock = mock((): { connected: string[] } | null => null)
const readProviderModelsCacheMock = mock((): ProviderModelsCache | null => null)
const shouldRetryErrorMock = mock(() => true)
const getNextFallbackMock = mock((chain: FallbackEntry[], attempt: number) => chain[attempt])
const hasMoreFallbacksMock = mock((chain: FallbackEntry[], attempt: number) => attempt < chain.length)
@@ -258,6 +260,20 @@ describe("tryFallbackRetry", () => {
expect(retryInput?.onSessionCreated).toBe(onSessionCreated)
})
test("preserves delegated launch context in retry input", async () => {
const args = createDefaultArgs({
skillContent: "delegated skill system",
sessionPermission: QUESTION_DENIED_SESSION_PERMISSION,
})
await tryFallbackRetry(args)
const key = `${args.task.model!.providerID}/${args.task.model!.modelID}`
const retryInput = args.queuesByKey.get(key)?.[0]?.input
expect(retryInput?.skillContent).toBe("delegated skill system")
expect(retryInput?.sessionPermission).toEqual(QUESTION_DENIED_SESSION_PERMISSION)
})
test("finalizes the failed attempt, creates a new pending attempt, and enqueues its explicit attemptID", async () => {
const args = createDefaultArgs({
status: "running",
@@ -416,7 +432,11 @@ describe("tryFallbackRetry", () => {
describe("#given disconnected fallback providers with connected preferred provider", () => {
test("keeps fallback entry and selects connected preferred provider", async () => {
readProviderModelsCacheMock.mockReturnValueOnce({ connected: ["provider-a"] })
readProviderModelsCacheMock.mockReturnValueOnce({
connected: ["provider-a"],
models: {},
updatedAt: new Date("2026-05-16T00:00:00.000Z").toISOString(),
})
selectFallbackProviderMock.mockImplementationOnce(
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
)