diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 36660a63c..823da5435 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1,10 +1,7 @@ declare const require: (name: string) => any const { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } = require("bun:test") -afterAll(() => { - mock.restore() - clearAllDelegatedChildSessionBootstrap() -}) +afterAll(() => { mock.restore() }) import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state" import { tmpdir } from "node:os" @@ -18,12 +15,6 @@ import { ConcurrencyManager } from "./concurrency" import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate" import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager" import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup" -import { - clearAllDelegatedChildSessionBootstrap, - getDelegatedChildSessionBootstrap, - registerDelegatedChildSessionBootstrap, -} from "../../shared/delegated-child-session-bootstrap" -import { SessionCategoryRegistry } from "../../shared/session-category-registry" const TASK_TTL_MS = 30 * 60 * 1000 @@ -388,37 +379,23 @@ describe("BackgroundManager session.error fallback hydration", () => { }) describe("BackgroundManager prompt rejection fallback routing", () => { - test("routes delegated child-session launch-time prompt rejections into tryFallbackRetry before history exists", async () => { + test("routes launch-time prompt rejections into tryFallbackRetry before marking interrupt", async () => { //#given const promptError = { name: "APIError", data: { message: "Forbidden: Selected provider is forbidden" }, } - const fallbackChain = [{ model: "claude-haiku-4-5", providers: ["anthropic"] }] - const messages = mock(async () => ({ data: [] })) - const bootstrapSnapshots: Array> = [] const client = { session: { get: async () => ({ data: { directory: tmpdir() } }), create: async () => ({ data: { id: "ses_launch_retry" } }), - messages, promptAsync: async () => { - bootstrapSnapshots.push(getDelegatedChildSessionBootstrap("ses_launch_retry")) throw promptError }, abort: async () => ({}), }, } - const setSessionFallbackChain = mock(() => {}) - const manager = new BackgroundManager({ - pluginContext: createPluginInput(client), - modelFallbackControllerAccessor: { - register: () => {}, - setSessionFallbackChain, - getSessionFallbackChain: () => undefined, - clearSessionFallbackChain: () => {}, - }, - }) + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) stubNotifyParentSession(manager) ;(cast<{ reserveSubagentSpawn: () => Promise<{ @@ -450,9 +427,8 @@ describe("BackgroundManager prompt rejection fallback routing", () => { agent: "sisyphus-junior", parentSessionId: "parent-session", parentMessageId: "parent-message", - category: "deep", model: { providerID: "genai-proxy-openai", modelID: "gpt-5.4-mini" }, - fallbackChain, + fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }], }) await flushBackgroundNotifications() @@ -465,72 +441,6 @@ describe("BackgroundManager prompt rejection fallback routing", () => { message: "Forbidden: Selected provider is forbidden", }) expect(storedTask?.status).toBe("pending") - expect(messages).not.toHaveBeenCalled() - expect(setSessionFallbackChain).toHaveBeenCalledWith("ses_launch_retry", fallbackChain) - expect(bootstrapSnapshots[0]?.retryParts[0]?.text).toContain("say hi") - }) - - test("clears delegated bootstrap and fallback context when launch-time prompt failure is terminal", async () => { - //#given - const promptError = new Error("Connection timeout") - const fallbackChain = [{ model: "claude-haiku-4-5", providers: ["anthropic"] }] - const clearSessionFallbackChain = mock(() => {}) - const client = { - session: { - get: async () => ({ data: { directory: tmpdir() } }), - create: async () => ({ data: { id: "ses_launch_terminal" } }), - promptAsync: async () => { - throw promptError - }, - abort: async () => ({}), - }, - } - const manager = new BackgroundManager({ - pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, - modelFallbackControllerAccessor: { - register: () => {}, - setSessionFallbackChain: () => {}, - getSessionFallbackChain: () => undefined, - clearSessionFallbackChain, - }, - }) - stubNotifyParentSession(manager) - ;(manager as unknown as { - reserveSubagentSpawn: () => Promise<{ - spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number } - descendantCount: number - commit: () => number - rollback: () => void - }> - }).reserveSubagentSpawn = async () => ({ - spawnContext: { rootSessionID: "parent-session", parentDepth: 0, childDepth: 1 }, - descendantCount: 1, - commit: () => 1, - rollback: () => {}, - }) - ;(manager as unknown as { - tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise - }).tryFallbackRetry = async () => false - - //#when - const launchedTask = await manager.launch({ - description: "background terminal retry test", - prompt: "say bye", - agent: "sisyphus-junior", - parentSessionId: "parent-session", - parentMessageId: "parent-message", - category: "deep", - model: { providerID: "genai-proxy-openai", modelID: "gpt-5.4-mini" }, - fallbackChain, - }) - await flushBackgroundNotifications() - - //#then - const storedTask = getTaskMap(manager).get(launchedTask.id) - expect(storedTask?.status).toBe("interrupt") - expect(getDelegatedChildSessionBootstrap("ses_launch_terminal")).toBeUndefined() - expect(clearSessionFallbackChain).toHaveBeenCalledWith("ses_launch_terminal") - expect(SessionCategoryRegistry.has("ses_launch_terminal")).toBe(false) }) test("routes resume-time prompt rejections into tryFallbackRetry before marking interrupt", async () => { @@ -749,60 +659,6 @@ describe("BackgroundManager retry observability", () => { expect(retryReadyNotification).toContain("Forbidden: Selected provider is forbidden") }) - test("clears delegated bootstrap and fallback context for the failed session when a fallback retry is scheduled", async () => { - //#given - const clearSessionFallbackChain = mock(() => {}) - const manager = createBackgroundManagerWithOptions({ - modelFallbackControllerAccessor: { - register: () => {}, - setSessionFallbackChain: () => {}, - getSessionFallbackChain: () => undefined, - clearSessionFallbackChain, - }, - }) - registerDelegatedChildSessionBootstrap({ - sessionID: "ses_retry_cleanup", - promptText: "retry me", - fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }], - category: "deep", - }) - const task = createMockTask({ - id: "bg_retry_cleanup", - sessionId: "ses_retry_cleanup", - parentSessionId: "parent-session", - status: "running", - attemptCount: 0, - fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }], - model: { providerID: "genai-proxy-openai", modelID: "gpt-5.4-mini" }, - concurrencyKey: "genai-proxy-openai/gpt-5.4-mini", - attempts: [ - { - attemptId: "att_retry_cleanup", - attemptNumber: 1, - sessionId: "ses_retry_cleanup", - providerId: "genai-proxy-openai", - modelId: "gpt-5.4-mini", - status: "running", - }, - ], - currentAttemptID: "att_retry_cleanup", - }) - - //#when - const retried = await (manager as unknown as { - tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise - }).tryFallbackRetry(task, { - name: "APIError", - message: "Forbidden: Selected provider is forbidden", - }, "promptAsync.launch") - - //#then - expect(retried).toBe(true) - expect(getDelegatedChildSessionBootstrap("ses_retry_cleanup")).toBeUndefined() - expect(clearSessionFallbackChain).toHaveBeenCalledWith("ses_retry_cleanup") - expect(SessionCategoryRegistry.has("ses_retry_cleanup")).toBe(false) - }) - test("builds retry-ready links from the parent session directory when it differs from the manager directory", async () => { //#given const queuePendingParentWake = mock(() => {}) @@ -2022,7 +1878,7 @@ describe("BackgroundManager.tryCompleteTask", () => { expect(concurrencyManager.getCount(concurrencyKey)).toBe(0) }) - test("should abort session on completion", async () => { + test("should abort session on completion", async () => { // #given const abortedSessionIDs: string[] = [] const client = { @@ -2059,47 +1915,6 @@ describe("BackgroundManager.tryCompleteTask", () => { expect(abortedSessionIDs).toEqual(["session-1"]) }) - test("should clear delegated bootstrap and fallback context on completion", async () => { - //#given - const clearSessionFallbackChain = mock(() => {}) - manager.shutdown() - manager = createBackgroundManagerWithOptions({ - modelFallbackControllerAccessor: { - register: () => {}, - setSessionFallbackChain: () => {}, - getSessionFallbackChain: () => undefined, - clearSessionFallbackChain, - }, - }) - stubNotifyParentSession(manager) - registerDelegatedChildSessionBootstrap({ - sessionID: "session-bootstrap-complete", - promptText: "complete me", - fallbackChain: [{ model: "fallback-1", providers: ["provider-a"] }], - category: "deep", - }) - - const task: BackgroundTask = { - id: "task-bootstrap-complete", - sessionId: "session-bootstrap-complete", - parentSessionId: "parent-bootstrap-complete", - parentMessageId: "msg-1", - description: "bootstrap completion task", - prompt: "test", - agent: "explore", - status: "running", - startedAt: new Date(), - } - - //#when - await tryCompleteTaskForTest(manager, task) - - //#then - expect(getDelegatedChildSessionBootstrap("session-bootstrap-complete")).toBeUndefined() - expect(clearSessionFallbackChain).toHaveBeenCalledWith("session-bootstrap-complete") - expect(SessionCategoryRegistry.has("session-bootstrap-complete")).toBe(false) - }) - test("should clean pendingByParent even when promptAsync notification fails", async () => { // given const client = { @@ -4022,46 +3837,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { manager.shutdown() resetToastManager() }) - - test("should clear delegated bootstrap and fallback context when cancelling a running task", async () => { - //#given - const clearSessionFallbackChain = mock(() => {}) - const manager = createBackgroundManagerWithOptions({ - modelFallbackControllerAccessor: { - register: () => {}, - setSessionFallbackChain: () => {}, - getSessionFallbackChain: () => undefined, - clearSessionFallbackChain, - }, - }) - registerDelegatedChildSessionBootstrap({ - sessionID: "session-cancel-bootstrap", - promptText: "cancel me", - fallbackChain: [{ model: "fallback-1", providers: ["provider-a"] }], - category: "deep", - }) - const task = createMockTask({ - id: "task-cancel-bootstrap", - sessionId: "session-cancel-bootstrap", - parentSessionId: "parent-cancel-bootstrap", - status: "running", - }) - getTaskMap(manager).set(task.id, task) - - //#when - const cancelled = await manager.cancelTask(task.id, { - source: "test", - skipNotification: true, - }) - - //#then - expect(cancelled).toBe(true) - expect(getDelegatedChildSessionBootstrap("session-cancel-bootstrap")).toBeUndefined() - expect(clearSessionFallbackChain).toHaveBeenCalledWith("session-cancel-bootstrap") - expect(SessionCategoryRegistry.has("session-cancel-bootstrap")).toBe(false) - - manager.shutdown() - }) }) describe("multiple keys process in parallel", () => { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 56750aa08..f5b2963e8 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -95,11 +95,6 @@ import { resolveSubagentSpawnContext, type SubagentSpawnContext, } from "./subagent-spawn-limits" -import { - clearDelegatedChildSessionBootstrap, - registerDelegatedChildSessionBootstrap, -} from "../../shared/delegated-child-session-bootstrap" -import { settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle" type OpencodeClient = PluginInput["client"] type ParentWakePromptContext = { @@ -323,12 +318,6 @@ export class BackgroundManager { } } - private cleanupDelegatedSessionContext(sessionID: string): void { - clearDelegatedChildSessionBootstrap(sessionID) - this.modelFallbackControllerAccessor?.clearSessionFallbackChain(sessionID) - SessionCategoryRegistry.remove(sessionID) - } - async assertCanSpawn(parentSessionID: string): Promise { const spawnContext = await resolveSubagentSpawnContext(this.client, parentSessionID, this.directory) const maxDepth = getMaxSubagentDepth(this.config) @@ -807,14 +796,6 @@ 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(), @@ -982,7 +963,6 @@ 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 => { @@ -1883,7 +1863,7 @@ The fallback retry session is now created and can be inspected directly. } this.rootDescendantCounts.delete(sessionID) - this.cleanupDelegatedSessionContext(sessionID) + SessionCategoryRegistry.remove(sessionID) } if (event.type === "session.status") { @@ -2071,7 +2051,7 @@ The fallback retry session is now created and can be inspected directly. } this.scheduleTaskRemoval(task.id) if (task.sessionId) { - this.cleanupDelegatedSessionContext(task.sessionId) + SessionCategoryRegistry.remove(task.sessionId) } // Update continuation marker for CLI run mode @@ -2129,7 +2109,6 @@ 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 } @@ -2293,7 +2272,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) - this.cleanupDelegatedSessionContext(task.sessionId) + SessionCategoryRegistry.remove(task.sessionId) } log("[background-agent] Removed completed task from memory:", taskId) }, TASK_CLEANUP_DELAY_MS) @@ -2368,7 +2347,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})`) - this.cleanupDelegatedSessionContext(task.sessionId) + SessionCategoryRegistry.remove(task.sessionId) } removeTaskToastTracking(task.id) @@ -2493,7 +2472,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})`) - this.cleanupDelegatedSessionContext(task.sessionId) + SessionCategoryRegistry.remove(task.sessionId) } // Update continuation marker for CLI run mode @@ -2942,7 +2921,7 @@ The task was re-queued on a fallback model after a retryable failure. removeTaskToastTracking(task.id) this.scheduleTaskRemoval(task.id) if (task.sessionId) { - this.cleanupDelegatedSessionContext(task.sessionId) + SessionCategoryRegistry.remove(task.sessionId) } // Update continuation marker for CLI run mode @@ -3157,7 +3136,7 @@ The task was re-queued on a fallback model after a retryable failure. for (const sessionID of trackedSessionIDs) { subagentSessions.delete(sessionID) - this.cleanupDelegatedSessionContext(sessionID) + SessionCategoryRegistry.remove(sessionID) } this.concurrencyManager.clear() diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index 46c0ee1dd..aa875b4ef 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -130,7 +130,7 @@ export function createAutoRetryHelpers(deps: HookDeps) { path: { id: sessionID }, query: { directory: ctx.directory }, }) - const retryParts = getLastUserRetryParts(messagesResp, sessionID) + const retryParts = getLastUserRetryParts(messagesResp) if (retryParts.length > 0) { log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, { sessionID, diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index 33dd30a6a..b3ae368fb 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -2,10 +2,6 @@ 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" import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type RuntimeFallbackModule = typeof import("./hook") @@ -20,7 +16,6 @@ describe("runtime-fallback", () => { logCalls = [] toastCalls = [] SessionCategoryRegistry.clear() - clearAllDelegatedChildSessionBootstrap() const cacheBuster = `${Date.now()}-${Math.random()}` @@ -37,7 +32,6 @@ describe("runtime-fallback", () => { afterEach(() => { SessionCategoryRegistry.clear() - clearAllDelegatedChildSessionBootstrap() mock.restore() }) @@ -2854,18 +2848,9 @@ describe("runtime-fallback", () => { expect(abortCalls.some((call) => call.path?.id === sessionID)).toBe(true) }) - test("delegated child-session empty-history fallback retries with captured bootstrap prompt", async () => { + test("pendingFallbackModel advances chain on subsequent error even when persisted", async () => { //#given - const promptCalls: Array> = [] - const hook = createRuntimeFallbackHook(createMockPluginInput({ - session: { - messages: async () => ({ data: [] }), - promptAsync: async (args) => { - promptCalls.push(args as Record) - return {} - }, - }, - }), { + const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig({ notify_on_fallback: false }), pluginConfig: { git_master: { @@ -2880,12 +2865,8 @@ describe("runtime-fallback", () => { }, }, }) - const sessionID = "test-delegated-empty-history-pending-persists" - registerDelegatedChildSessionBootstrap({ - sessionID, - promptText: "delegated retry payload", - category: "test", - }) + const sessionID = "test-race-pending-persists" + SessionCategoryRegistry.register(sessionID, "test") await hook.event({ event: { @@ -2901,13 +2882,8 @@ 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).toBeUndefined() + expect(autoRetryLog).toBeDefined() //#when - second error fires after retry completed (retryInFlight cleared) await hook.event({ @@ -2921,54 +2897,5 @@ 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> = [] - const hook = createRuntimeFallbackHook(createMockPluginInput({ - session: { - messages: async () => ({ data: [] }), - promptAsync: async (args) => { - promptCalls.push(args as Record) - 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) - }) }) }) diff --git a/src/hooks/runtime-fallback/last-user-retry-parts.ts b/src/hooks/runtime-fallback/last-user-retry-parts.ts index 98aa4f16e..899572a73 100644 --- a/src/hooks/runtime-fallback/last-user-retry-parts.ts +++ b/src/hooks/runtime-fallback/last-user-retry-parts.ts @@ -1,9 +1,7 @@ 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() @@ -11,7 +9,7 @@ export function getLastUserRetryParts( lastUserMessage?.parts ?? (lastUserMessage?.info?.parts as Array<{ type?: string; text?: string }> | undefined) - const retryParts = (lastUserParts ?? []) + return (lastUserParts ?? []) .filter( (part): part is { type: "text"; text: string } => part.type === "text" @@ -19,12 +17,4 @@ 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 ?? []) - : [] } diff --git a/src/shared/delegated-child-session-bootstrap.ts b/src/shared/delegated-child-session-bootstrap.ts deleted file mode 100644 index 2758411d8..000000000 --- a/src/shared/delegated-child-session-bootstrap.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { FallbackEntry } from "./model-requirements" -import type { ModelFallbackControllerAccessor } from "../hooks/model-fallback" -import { createInternalAgentTextPart } from "./internal-initiator-marker" -import { SessionCategoryRegistry } from "./session-category-registry" - -export type DelegatedChildSessionRetryPart = { - type: "text" - text: string -} - -export type DelegatedChildSessionBootstrap = { - retryParts: DelegatedChildSessionRetryPart[] -} - -const delegatedChildSessionBootstrapMap = new Map() - -export function createDelegatedChildSessionRetryParts(promptText: string): DelegatedChildSessionRetryPart[] { - return [createInternalAgentTextPart(promptText)] -} - -export function registerDelegatedChildSessionBootstrap(args: { - sessionID: string - promptText: string - fallbackChain?: FallbackEntry[] - category?: string - modelFallbackControllerAccessor?: ModelFallbackControllerAccessor -}): void { - delegatedChildSessionBootstrapMap.set(args.sessionID, { - retryParts: createDelegatedChildSessionRetryParts(args.promptText), - }) - - args.modelFallbackControllerAccessor?.setSessionFallbackChain(args.sessionID, args.fallbackChain) - if (args.category) { - SessionCategoryRegistry.register(args.sessionID, args.category) - } -} - -export function getDelegatedChildSessionBootstrap(sessionID: string): DelegatedChildSessionBootstrap | undefined { - const bootstrap = delegatedChildSessionBootstrapMap.get(sessionID) - if (!bootstrap) { - return undefined - } - - return { - retryParts: bootstrap.retryParts.map((part) => ({ ...part })), - } -} - -export function clearDelegatedChildSessionBootstrap(sessionID: string): void { - delegatedChildSessionBootstrapMap.delete(sessionID) -} - -export function clearAllDelegatedChildSessionBootstrap(): void { - delegatedChildSessionBootstrapMap.clear() -} diff --git a/src/tools/delegate-task/background-task.ts b/src/tools/delegate-task/background-task.ts index 8fabf3548..767bab764 100644 --- a/src/tools/delegate-task/background-task.ts +++ b/src/tools/delegate-task/background-task.ts @@ -6,31 +6,26 @@ 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" -import { registerDelegatedChildSessionBootstrap } from "../../shared/delegated-child-session-bootstrap" function registerBackgroundSessionContext(args: { sessionId: string - promptText: string fallbackChain?: FallbackEntry[] category?: string modelFallbackControllerAccessor?: ExecutorContext["modelFallbackControllerAccessor"] }): void { - registerDelegatedChildSessionBootstrap({ - sessionID: args.sessionId, - promptText: args.promptText, - fallbackChain: args.fallbackChain, - category: args.category, - modelFallbackControllerAccessor: args.modelFallbackControllerAccessor, - }) + args.modelFallbackControllerAccessor?.setSessionFallbackChain(args.sessionId, args.fallbackChain) + if (args.category) { + SessionCategoryRegistry.register(args.sessionId, args.category) + } } function continueSessionSetup(args: { taskID: string - promptText: string manager: ExecutorContext["manager"] timing: ReturnType fallbackChain?: FallbackEntry[] @@ -60,7 +55,6 @@ function continueSessionSetup(args: { registerBackgroundSessionContext({ sessionId, - promptText: args.promptText, fallbackChain: args.fallbackChain, category: args.category, modelFallbackControllerAccessor: args.modelFallbackControllerAccessor, @@ -150,7 +144,6 @@ export async function executeBackgroundTask( onAbort: () => { continueSessionSetup({ taskID: task.id, - promptText: effectivePrompt, manager, timing, fallbackChain, @@ -167,6 +160,15 @@ 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, diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index af9963231..bfdac5fba 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -58,7 +58,6 @@ export async function sendSyncPrompt( sessionID: string agentToUse: string args: DelegateTaskArgs - promptText?: string systemContent: string | undefined categoryModel: DelegatedModelConfig | undefined directory: string @@ -70,7 +69,7 @@ export async function sendSyncPrompt( ): Promise { const allowTask = isPlanFamily(input.agentToUse) const tddEnabled = input.sisyphusAgentConfig?.tdd - const effectivePrompt = input.promptText ?? buildTaskPrompt(input.args.prompt, input.agentToUse, tddEnabled) + const effectivePrompt = buildTaskPrompt(input.args.prompt, input.agentToUse, tddEnabled) const tools = { task: allowTask, call_omo_agent: true, diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index f4792606a..e1d792cfe 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -29,8 +29,6 @@ describe("executeSyncTask - cleanup on error paths", () => { addCalls = [] clearRequireCache("./sync-task") - const { clearAllDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") - clearAllDelegatedChildSessionBootstrap() const { initTaskToastManager, _resetTaskToastManagerForTesting } = require("../../features/task-toast-manager/manager") _resetTaskToastManagerForTesting() @@ -64,8 +62,6 @@ describe("executeSyncTask - cleanup on error paths", () => { mock.restore() resetToastManager?.() resetToastManager = null - const { clearAllDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") - clearAllDelegatedChildSessionBootstrap() }) test("cleans up toast and subagentSessions when fetchSyncResult returns ok: false", async () => { @@ -389,35 +385,16 @@ describe("executeSyncTask - cleanup on error paths", () => { } const { executeSyncTask } = require("./sync-task") - const { getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") const attemptedModels: Array<{ providerID: string; modelID: string; variant?: string } | undefined> = [] - const promptSessionIDs: string[] = [] - const pollSessionIDs: string[] = [] - const fetchSessionIDs: string[] = [] - const bootstrapSnapshots: Array<{ retryParts: Array<{ type: "text"; text: string }> } | undefined> = [] - let createSyncSessionCalls = 0 - const setSessionFallbackChain = mock(() => {}) - const clearSessionFallbackChain = mock(() => {}) const deps = { - createSyncSession: async () => { - createSyncSessionCalls += 1 - return { ok: true as const, sessionID: "ses_test_12345678" } - }, - sendSyncPrompt: async (_client: unknown, input: { sessionID: string; categoryModel?: { providerID: string; modelID: string; variant?: string } }) => { - promptSessionIDs.push(input.sessionID) - bootstrapSnapshots.push(getDelegatedChildSessionBootstrap(input.sessionID)) + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async (_client: unknown, input: { categoryModel?: { providerID: string; modelID: string; variant?: string } }) => { attemptedModels.push(input.categoryModel) return attemptedModels.length === 1 ? "Initial failure" : null }, - pollSyncSession: async (_ctx: unknown, _client: unknown, input: { sessionID: string }) => { - pollSessionIDs.push(input.sessionID) - return null - }, - fetchSyncResult: async (_client: unknown, sessionID: string) => { - fetchSessionIDs.push(sessionID) - return { ok: true as const, textContent: "Result" } - }, + pollSyncSession: async () => null, + fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), } const mockCtx = { @@ -430,10 +407,6 @@ describe("executeSyncTask - cleanup on error paths", () => { client: mockClient, directory: "/tmp", onSyncSessionCreated: null, - modelFallbackControllerAccessor: { - setSessionFallbackChain, - clearSessionFallbackChain, - }, } const args = { @@ -467,14 +440,6 @@ describe("executeSyncTask - cleanup on error paths", () => { { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "max" }, { providerID: "opencode-go", modelID: "kimi-k2.6", variant: undefined }, ]) - expect(setSessionFallbackChain).toHaveBeenCalledWith("ses_test_12345678", fallbackChain) - expect(bootstrapSnapshots[0]?.retryParts[0]?.text).toContain("test prompt") - expect(createSyncSessionCalls).toBe(1) - expect(promptSessionIDs).toEqual(["ses_test_12345678", "ses_test_12345678"]) - expect(pollSessionIDs).toEqual(["ses_test_12345678"]) - expect(fetchSessionIDs).toEqual(["ses_test_12345678"]) - expect(clearSessionFallbackChain).toHaveBeenCalledWith("ses_test_12345678") - expect(getDelegatedChildSessionBootstrap("ses_test_12345678")).toBeUndefined() }) test("#given fallback chain exhausted #when all retries fail #then returns final error", async () => { @@ -545,110 +510,6 @@ describe("executeSyncTask - cleanup on error paths", () => { ]) }) - test("keeps concurrent delegated first-prompt fallback bootstrap isolated per session", async () => { - const mockClient = { - session: { - create: async () => ({ data: { id: "ignored" } }), - }, - } - - const { executeSyncTask } = require("./sync-task") - const { getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") - const perSessionAttempts = new Map() - const bootstrapSnapshots: Array<{ sessionID: string; text: string | undefined }> = [] - const setSessionFallbackChain = mock(() => {}) - const clearSessionFallbackChain = mock(() => {}) - - const deps = { - createSyncSession: async (_client: unknown, input: { description: string }) => { - return { - ok: true as const, - sessionID: input.description === "alpha task" ? "ses_alpha" : "ses_beta", - } - }, - sendSyncPrompt: async (_client: unknown, input: { sessionID: string; categoryModel?: { providerID: string; modelID: string; variant?: string } }) => { - const bootstrap = getDelegatedChildSessionBootstrap(input.sessionID) - bootstrapSnapshots.push({ - sessionID: input.sessionID, - text: bootstrap?.retryParts[0]?.text, - }) - const currentAttempt = (perSessionAttempts.get(input.sessionID) ?? 0) + 1 - perSessionAttempts.set(input.sessionID, currentAttempt) - return currentAttempt === 1 ? `Initial failure for ${input.sessionID}` : null - }, - pollSyncSession: async () => null, - fetchSyncResult: async (_client: unknown, sessionID: string) => ({ ok: true as const, textContent: `Result from ${sessionID}` }), - } - - const mockExecutorCtx = { - client: mockClient, - directory: "/tmp", - onSyncSessionCreated: null, - modelFallbackControllerAccessor: { - setSessionFallbackChain, - clearSessionFallbackChain, - }, - } - - const alphaArgs = { - prompt: "alpha delegated prompt", - description: "alpha task", - category: "test", - load_skills: [], - run_in_background: false, - command: null, - } - const betaArgs = { - prompt: "beta delegated prompt", - description: "beta task", - category: "test", - load_skills: [], - run_in_background: false, - command: null, - } - const mockCtx = { - sessionID: "parent-session", - callID: "call-123", - metadata: () => {}, - } - - const [alphaResult, betaResult] = await Promise.all([ - executeSyncTask(alphaArgs, mockCtx, mockExecutorCtx, { sessionID: "parent-session" }, "test-agent", { - providerID: "anthropic", - modelID: "claude-opus-4-7", - variant: "max", - }, undefined, undefined, [ - { providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" }, - { providers: ["openai"], model: "gpt-5.4" }, - ], deps), - executeSyncTask(betaArgs, mockCtx, mockExecutorCtx, { sessionID: "parent-session" }, "test-agent", { - providerID: "genai-proxy-openai", - modelID: "gpt-5.4-mini", - variant: undefined, - }, undefined, undefined, [ - { providers: ["genai-proxy-openai"], model: "gpt-5.4-mini" }, - { providers: ["genai-proxy-aws"], model: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, - ], deps), - ]) - - expect(alphaResult).toContain("Result from ses_alpha") - expect(betaResult).toContain("Result from ses_beta") - expect(bootstrapSnapshots.filter((snapshot) => snapshot.sessionID === "ses_alpha").every((snapshot) => snapshot.text?.includes("alpha delegated prompt"))).toBe(true) - expect(bootstrapSnapshots.filter((snapshot) => snapshot.sessionID === "ses_beta").every((snapshot) => snapshot.text?.includes("beta delegated prompt"))).toBe(true) - expect(setSessionFallbackChain).toHaveBeenCalledWith("ses_alpha", [ - { providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" }, - { providers: ["openai"], model: "gpt-5.4" }, - ]) - expect(setSessionFallbackChain).toHaveBeenCalledWith("ses_beta", [ - { providers: ["genai-proxy-openai"], model: "gpt-5.4-mini" }, - { providers: ["genai-proxy-aws"], model: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, - ]) - expect(clearSessionFallbackChain).toHaveBeenCalledWith("ses_alpha") - expect(clearSessionFallbackChain).toHaveBeenCalledWith("ses_beta") - expect(getDelegatedChildSessionBootstrap("ses_alpha")).toBeUndefined() - expect(getDelegatedChildSessionBootstrap("ses_beta")).toBeUndefined() - }) - test("cleans up toast and subagentSessions on successful completion", async () => { const mockClient = { session: { @@ -714,7 +575,6 @@ describe("executeSyncTask - cleanup on error paths", () => { }) test("retries sync session on retryable runtime session error using next fallback model", async () => { - const { getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") const mockClient = { session: { create: async () => ({ data: { id: "ignored" } }), @@ -793,8 +653,6 @@ describe("executeSyncTask - cleanup on error paths", () => { ]) expect(result).toContain("Result from ses_second") expect(deleteCalls).toContain("ses_first") - expect(getDelegatedChildSessionBootstrap("ses_first")).toBeUndefined() - expect(getDelegatedChildSessionBootstrap("ses_second")).toBeUndefined() const finalMetadata = metadataCalls[metadataCalls.length - 1] expect(finalMetadata.metadata.sessionId).toBe("ses_second") @@ -882,7 +740,6 @@ describe("executeSyncTask - cleanup on error paths", () => { }) test("publishes latest retry session metadata when final retry still fails", async () => { - const { getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") const mockClient = { session: { create: async () => ({ data: { id: "ignored" } }), @@ -948,9 +805,7 @@ describe("executeSyncTask - cleanup on error paths", () => { }, "sisyphus-junior", initialModel, undefined, undefined, fallbackChain, deps) expect(result).toBe("Final retry failed") - expect(getDelegatedChildSessionBootstrap("ses_first")).toBeUndefined() - expect(getDelegatedChildSessionBootstrap("ses_second")).toBeUndefined() - const finalMetadata = metadataCalls.at(-1) + const finalMetadata = metadataCalls[metadataCalls.length - 1] expect(finalMetadata.metadata.sessionId).toBe("ses_second") expect(finalMetadata.metadata.taskId).toBe("ses_second") expect(finalMetadata.metadata.model).toEqual({ @@ -1082,3 +937,5 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(taskMeta.metadata.spawnDepth).toBe(3) // NOT 1 (the fallback value) }) }) + +export {} diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 06686ee13..8978d933a 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -14,11 +14,6 @@ import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task- import { resolveMetadataModel } from "./resolve-metadata-model" import { shouldRetryError } from "../../shared/model-error-classifier" import type { ModelFallbackState } from "../../hooks/model-fallback/hook" -import { buildTaskPrompt } from "./prompt-builder" -import { - clearDelegatedChildSessionBootstrap, - registerDelegatedChildSessionBootstrap, -} from "../../shared/delegated-child-session-bootstrap" function shouldAttemptPollErrorRecovery(pollError: string): boolean { const trimmed = pollError.trim() @@ -67,9 +62,6 @@ export async function executeSyncTask( | undefined try { - const tddEnabled = executorCtx.sisyphusAgentConfig?.tdd - const delegatedPromptText = buildTaskPrompt(args.prompt, agentToUse, tddEnabled) - if (typeof manager?.reserveSubagentSpawn === "function") { spawnReservation = await manager.reserveSubagentSpawn(parentContext.sessionID) } @@ -114,13 +106,11 @@ export async function executeSyncTask( subagentSessions.add(newSessionID) syncSubagentSessions.add(newSessionID) setSessionAgent(newSessionID, agentToUse) - registerDelegatedChildSessionBootstrap({ - sessionID: newSessionID, - promptText: delegatedPromptText, - fallbackChain, - category: args.category, - modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor, - }) + executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(newSessionID, fallbackChain) + + if (args.category) { + SessionCategoryRegistry.register(newSessionID, args.category) + } if (onSyncSessionCreated) { log("[task] Invoking onSyncSessionCreated callback", { sessionID: newSessionID, parentID: parentContext.sessionID }) @@ -186,7 +176,6 @@ export async function executeSyncTask( sessionID, agentToUse, args, - promptText: delegatedPromptText, systemContent, directory: createSessionResult.parentDirectory, toastManager, @@ -209,7 +198,6 @@ export async function executeSyncTask( const cleanupRetrySession = (currentSessionID: string): void => { subagentSessions.delete(currentSessionID) syncSubagentSessions.delete(currentSessionID) - clearDelegatedChildSessionBootstrap(currentSessionID) executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(currentSessionID) SessionCategoryRegistry.remove(currentSessionID) } @@ -374,7 +362,6 @@ ${buildTaskMetadataBlock({ if (syncSessionID) { subagentSessions.delete(syncSessionID) syncSubagentSessions.delete(syncSessionID) - clearDelegatedChildSessionBootstrap(syncSessionID) executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(syncSessionID) SessionCategoryRegistry.remove(syncSessionID) }