Merge pull request #4074 from code-yeongyu/fix/delegate-task-spawn

fix(delegate-task): start child prompts reliably
This commit is contained in:
YeonGyu-Kim
2026-05-17 01:00:03 +09:00
committed by GitHub
22 changed files with 1385 additions and 216 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",
)
@@ -197,6 +197,8 @@ export async function tryFallbackRetry(args: {
teamRunId: task.teamRunId,
model: nextModel,
fallbackChain: task.fallbackChain,
skillContent: task.skillContent,
sessionPermission: task.sessionPermission,
category: task.category,
isUnstableAgent: task.isUnstableAgent,
onSessionCreated: task.onSessionCreated,
+412 -28
View File
@@ -1,21 +1,32 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } = require("bun:test")
import { tmpdir } from "node:os"
import { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import * as sharedModule from "../../shared"
import {
clearAllDelegatedChildSessionBootstrap,
getDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
import { clearSessionPromptParams, getSessionPromptParams } from "../../shared/session-prompt-params-state"
import {
getSessionAgent,
registerAgentName,
_resetForTesting as resetClaudeCodeSessionState,
subagentSessions,
} from "../claude-code-session-state"
import { _resetTaskToastManagerForTesting, initTaskToastManager } from "../task-toast-manager/manager"
import type { ConcurrencyManager } from "./concurrency"
import { MIN_IDLE_TIME_MS } from "./constants"
import { BackgroundManager } from "./manager"
import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup"
import { clearBackgroundTaskRegistryForTesting } from "./task-registry"
import type { BackgroundTask, ResumeInput } from "./types"
afterAll(() => { mock.restore() })
import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import * as sharedModule from "../../shared"
import { _resetForTesting as resetClaudeCodeSessionState, registerAgentName, subagentSessions } from "../claude-code-session-state"
import type { BackgroundTask, ResumeInput } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants"
import { BackgroundManager } from "./manager"
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"
afterEach(() => {
clearBackgroundTaskRegistryForTesting()
})
const TASK_TTL_MS = 30 * 60 * 1000
type PendingParentWakeForTest = {
@@ -186,6 +197,30 @@ function cast<T>(value: unknown): T {
return value as T
}
async function expectRejectsWithMessage(promise: Promise<unknown>, expectedMessage: string): Promise<void> {
await promise.then(
() => {
throw new Error(`Expected promise to reject with ${expectedMessage}`)
},
(error: unknown) => {
expect(String(error)).toContain(expectedMessage)
},
)
}
async function expectResolvesDefined(promise: Promise<unknown>): Promise<void> {
const result = await promise
expect(result).toBeDefined()
}
async function expectResolvesMatchObject<TActual extends object>(
promise: Promise<TActual>,
expected: Partial<TActual>,
): Promise<void> {
const result = await promise
expect(result).toMatchObject(expected)
}
function createPluginInput(client: unknown, directory = tmpdir()): PluginInput {
return cast<PluginInput>({ client, directory })
}
@@ -458,6 +493,77 @@ describe("BackgroundManager session.error fallback hydration", () => {
})
})
describe("BackgroundManager delegated child-session bootstrap", () => {
test("registers launch bootstrap before first prompt and clears it after completion", async () => {
//#given
clearAllDelegatedChildSessionBootstrap()
const observedBootstrapPrompts: string[] = []
const client = {
session: {
get: async () => ({ data: { directory: tmpdir() } }),
create: async () => ({ data: { id: "ses_background_bootstrap" } }),
promptAsync: async () => {
const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap")
observedBootstrapPrompts.push(bootstrap?.retryParts[0]?.text ?? "")
return {}
},
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
stubNotifyParentSession(manager)
const task = createMockTask({
id: "bg_bootstrap",
parentSessionId: "parent-session",
status: "pending",
queuedAt: new Date(),
prompt: "background bootstrap prompt",
agent: "sisyphus-junior",
skillContent: "background delegated skill system",
category: "quick",
model: { providerID: "anthropic", modelID: "claude-haiku-4-5" },
fallbackChain: [{ model: "gpt-5.4", providers: ["openai"], variant: "high" }],
})
getTaskMap(manager).set(task.id, task)
const input = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionId: task.parentSessionId,
parentMessageId: task.parentMessageId,
parentModel: task.parentModel,
parentAgent: task.parentAgent,
parentTools: task.parentTools,
model: task.model,
fallbackChain: task.fallbackChain,
skillContent: task.skillContent,
category: task.category,
}
try {
//#when
await (cast<{ startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }>(manager))
.startTask({ task, input })
await flushBackgroundNotifications()
//#then
expect(observedBootstrapPrompts[0]).toContain("background bootstrap prompt")
const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap")
expect(bootstrap?.system).toBe("background delegated skill system")
expect(bootstrap?.tools?.question).toBe(false)
expect(bootstrap?.tools?.task).toBe(false)
expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeDefined()
const completed = await tryCompleteTaskForTest(manager, task)
expect(completed).toBe(true)
expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeUndefined()
} finally {
manager.shutdown()
clearAllDelegatedChildSessionBootstrap()
}
})
})
describe("BackgroundManager prompt rejection fallback routing", () => {
test("routes launch-time prompt rejections into tryFallbackRetry before marking interrupt", async () => {
//#given
@@ -635,7 +741,13 @@ describe("BackgroundManager retry observability", () => {
//#then
expect(queuePendingParentWake).toHaveBeenCalledTimes(1)
const [sessionID, notification, promptContext, shouldReply] = queuePendingParentWake.mock.calls[0]
const retryingCall = cast<Array<[string, string, Record<string, unknown>, boolean]>>(
queuePendingParentWake.mock.calls,
)[0]
if (!retryingCall) {
throw new Error("Expected retrying parent wake call")
}
const [sessionID, notification, promptContext, shouldReply] = retryingCall
expect(sessionID).toBe("parent-session")
expect(promptContext).toEqual({})
expect(shouldReply).toBe(false)
@@ -2767,7 +2879,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("Agent parameter is required after sanitization")
await expectRejectsWithMessage(result, "Agent parameter is required after sanitization")
})
test("should initialize attempt state for a newly launched task", async () => {
@@ -3089,7 +3201,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDepth=3")
await expectRejectsWithMessage(result, "background_task.maxDepth=3")
})
test("allows multiple descendants without a root spawn cap", async () => {
@@ -3118,7 +3230,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const result = manager.launch(input)
// then
await expect(result).resolves.toBeDefined()
await expectResolvesDefined(result)
})
test("allows spawn assertions after reserveSubagentSpawn without a root spawn cap", async () => {
@@ -3139,7 +3251,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const result = manager.assertCanSpawn("session-root")
// then
await expect(result).resolves.toMatchObject({
await expectResolvesMatchObject(result, {
rootSessionID: "session-root",
childDepth: 1,
})
@@ -3172,7 +3284,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDepth cannot be enforced safely")
await expectRejectsWithMessage(result, "background_task.maxDepth cannot be enforced safely")
})
test("allows replacement launch when a queued task is cancelled before session starts", async () => {
@@ -3672,7 +3784,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
// Complete via internal method (session.status events go through the poller, not handleEvent)
await tryCompleteTaskForTest(manager, internalTask)
await expect(manager.launch(input)).resolves.toBeDefined()
await expectResolvesDefined(manager.launch(input))
})
test("allows relaunch after running task is cancelled", async () => {
@@ -3701,7 +3813,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
await manager.cancelTask(task.id)
await expect(manager.launch(input)).resolves.toBeDefined()
await expectResolvesDefined(manager.launch(input))
})
test("allows relaunch after task errors", async () => {
@@ -3734,7 +3846,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
})
await new Promise((resolve) => setTimeout(resolve, 100))
await expect(manager.launch(input)).resolves.toBeDefined()
await expectResolvesDefined(manager.launch(input))
})
test("allows repeated relaunch after pending tasks are cancelled", async () => {
@@ -3762,8 +3874,8 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
await manager.cancelTask(task1.id)
await manager.cancelTask(task2.id)
await expect(manager.launch(input)).resolves.toBeDefined()
await expect(manager.launch(input)).resolves.toBeDefined()
await expectResolvesDefined(manager.launch(input))
await expectResolvesDefined(manager.launch(input))
})
})
@@ -4977,10 +5089,12 @@ describe("BackgroundManager.handleEvent - session.error", () => {
const mockVerifySessionExists = (manager: BackgroundManager, sessionExists: boolean): void => {
verifySessionExistsSpy?.mockRestore()
verifySessionExistsSpy = spyOn(
const spy = spyOn(
cast<{ verifySessionExists: (sessionID: string) => Promise<boolean> }>(manager),
"verifySessionExists",
).mockResolvedValue(sessionExists)
)
spy.mockImplementation(async () => sessionExists)
verifySessionExistsSpy = spy
}
const stubProcessKey = (manager: BackgroundManager) => {
@@ -6721,6 +6835,157 @@ describe("BackgroundManager regression fixes - resume and aborted notification",
manager.shutdown()
})
test("should resolve a completed task registered by an earlier plugin manager instance", () => {
//#given
const firstManager = createBackgroundManager()
const secondManager = createBackgroundManager()
const task: BackgroundTask = {
id: "task-cross-manager-regression",
sessionId: "session-cross-manager-regression",
parentSessionId: "parent-session",
parentMessageId: "msg-1",
description: "cross manager regression",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
//#when
;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)
//#then
const resolvedTask = secondManager.getTask(task.id)
expect(resolvedTask?.sessionId).toBe(task.sessionId)
firstManager.shutdown()
secondManager.shutdown()
})
test("should redact active task prompts resolved from an earlier plugin manager instance", () => {
//#given
const firstManager = createBackgroundManager()
const secondManager = createBackgroundManager()
const task: BackgroundTask = {
id: "task-cross-manager-active-redaction",
parentSessionId: "parent-session",
parentMessageId: "msg-1",
description: "cross manager active redaction",
prompt: "secret prompt",
agent: "explore",
status: "pending",
queuedAt: new Date(),
}
//#when
;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)
task.sessionId = "session-cross-manager-active-redaction"
task.status = "running"
task.startedAt = new Date()
task.progress = {
lastUpdate: new Date(),
toolCalls: 1,
countedToolPartIDs: new Set(["part-1"]),
}
//#then
const localTask = firstManager.getTask(task.id)
const registeredTask = secondManager.getTask(task.id)
expect(localTask?.prompt).toBe("secret prompt")
expect(registeredTask?.sessionId).toBe(task.sessionId)
expect(registeredTask?.prompt).toBe("[redacted]")
expect(registeredTask?.progress?.countedToolPartIDs).toEqual(new Set(["part-1"]))
firstManager.shutdown()
secondManager.shutdown()
})
test("should resolve archived completed task from an earlier plugin manager instance", () => {
//#given
const firstManager = createBackgroundManager()
const secondManager = createBackgroundManager()
const task: BackgroundTask = {
id: "task-cross-manager-archive-regression",
sessionId: "session-cross-manager-archive-regression",
parentSessionId: "parent-session",
parentMessageId: "msg-1",
description: "cross manager archive regression",
prompt: "sensitive prompt",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(firstManager).set(task.id, task)
//#when
;(cast<{ removeTask: (task: BackgroundTask) => void }>(firstManager)).removeTask(task)
//#then
const resolvedTask = secondManager.getTask(task.id)
expect(resolvedTask?.sessionId).toBe(task.sessionId)
expect(resolvedTask?.prompt).toBe("[redacted]")
firstManager.shutdown()
secondManager.shutdown()
})
test("should archive terminal registry tasks during earlier manager shutdown", async () => {
//#given
const firstManager = createBackgroundManager()
const secondManager = createBackgroundManager()
const task: BackgroundTask = {
id: "task-shutdown-archive-regression",
sessionId: "session-shutdown-archive-regression",
parentSessionId: "parent-session",
parentMessageId: "msg-1",
description: "shutdown archive regression",
prompt: "sensitive shutdown prompt",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)
//#when
await firstManager.shutdown()
//#then
const resolvedTask = secondManager.getTask(task.id)
expect(resolvedTask?.sessionId).toBe(task.sessionId)
expect(resolvedTask?.prompt).toBe("[redacted]")
await secondManager.shutdown()
})
test("should forget active registry tasks during earlier manager shutdown", async () => {
//#given
const firstManager = createBackgroundManager()
const secondManager = createBackgroundManager()
const task: BackgroundTask = {
id: "task-shutdown-active-regression",
sessionId: "session-shutdown-active-regression",
parentSessionId: "parent-session",
parentMessageId: "msg-1",
description: "shutdown active regression",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
}
;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task)
//#when
await firstManager.shutdown()
//#then
expect(secondManager.getTask(task.id)).toBeUndefined()
await secondManager.shutdown()
})
test("should cap completed task archive size at 100 entries", () => {
//#given
const manager = createBackgroundManager()
@@ -6848,6 +7113,62 @@ describe("BackgroundManager - tool permission spread order", () => {
manager.shutdown()
})
test("startTask updates tracked session agent when launch falls back to general", async () => {
//#given
const promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
let promptCallCount = 0
const client = {
session: {
get: async () => ({ data: { directory: "/test/dir" } }),
create: async () => ({ data: { id: "session-manager-fallback" } }),
promptAsync: async (args: { path: { id: string }; body: Record<string, unknown> }) => {
promptCallCount++
promptCalls.push(args)
if (promptCallCount === 1) {
throw new Error("Agent not found: missing-agent")
}
return {}
},
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
const task: BackgroundTask = {
id: "task-manager-fallback",
status: "pending",
queuedAt: new Date(),
description: "test task",
prompt: "test prompt",
agent: "missing-agent",
parentSessionId: "parent-session",
parentMessageId: "parent-message",
}
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionId: task.parentSessionId,
parentMessageId: task.parentMessageId,
}
try {
//#when
await (cast<{ startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise<void> }>(manager))
.startTask({ task, input })
await new Promise((resolve) => setTimeout(resolve, 50))
//#then
expect(promptCalls).toHaveLength(2)
expect(promptCalls[0].body.agent).toBe("missing-agent")
expect(promptCalls[1].body.agent).toBe("general")
expect(task.agent).toBe("general")
expect(getSessionAgent("session-manager-fallback")).toBe("general")
expect(getDelegatedChildSessionBootstrap("session-manager-fallback")?.tools?.call_omo_agent).toBe(true)
} finally {
manager.shutdown()
clearAllDelegatedChildSessionBootstrap()
}
})
test("resume respects explore agent restrictions", async () => {
//#given
let capturedTools: Record<string, unknown> | undefined
@@ -6992,6 +7313,7 @@ describe("BackgroundManager.launch - attempt state initialization", () => {
describe("BackgroundManager attempt lifecycle bindings", () => {
test("startTask binds the created session to the queued attempt ID and mirrors task projection", async () => {
//#given
resetClaudeCodeSessionState()
const client = {
session: {
get: async () => ({ data: { directory: "/test/dir" } }),
@@ -7064,6 +7386,68 @@ describe("BackgroundManager attempt lifecycle bindings", () => {
status: "error",
error: "first attempt failed",
})
expect(getSessionAgent("session-attempt-2")).toBe("sisyphus-junior")
manager.shutdown()
})
test("startTask clears child session agent state when task is cancelled before launch binding", async () => {
//#given
resetClaudeCodeSessionState()
const sessionID = "session-cancelled-prelaunch"
const client = {
session: {
get: async () => ({ data: { directory: "/test/dir" } }),
create: async () => ({ data: { id: sessionID } }),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
const task: BackgroundTask = {
id: "task-cancel-prelaunch",
status: "pending",
queuedAt: new Date(),
description: "cancel before bind",
prompt: "continue",
agent: "sisyphus-junior",
parentSessionId: "parent-session",
parentMessageId: "parent-message",
model: { providerID: "anthropic", modelID: "claude-haiku-4.5" },
attempts: [
{
attemptId: "attempt-1",
attemptNumber: 1,
providerId: "anthropic",
modelId: "claude-haiku-4.5",
status: "pending",
},
],
currentAttemptID: "attempt-1",
attemptCount: 1,
}
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionId: task.parentSessionId,
parentMessageId: task.parentMessageId,
model: task.model,
onSessionCreated: async () => {
// simulate parent flipping task to cancelled between create and bind
task.status = "cancelled"
const internal = cast<{ tasks: Map<string, BackgroundTask> }>(manager)
internal.tasks.set(task.id, task)
},
}
//#when
await (cast<{
startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput; attemptID: string }) => Promise<void>
}>(manager)).startTask({ task, input, attemptID: "attempt-1" })
//#then
expect(getSessionAgent(sessionID)).toBeUndefined()
manager.shutdown()
})
+151 -89
View File
@@ -1,99 +1,107 @@
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
import { isAgentNotFoundError, FALLBACK_AGENT, buildFallbackBody } from "./spawner"
import type {
BackgroundTask,
BackgroundTaskAttempt,
LaunchInput,
ResumeInput,
} from "./types"
import { TaskHistory } from "./task-history"
import { type PromptAsyncGateResult, promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle"
import {
log,
createInternalAgentTextPart,
getAgentToolRestrictions,
log,
messagesInDirectory,
normalizePromptTools,
normalizeSDKResponse,
resolveInheritedPromptTools,
createInternalAgentTextPart,
messagesInDirectory,
promptWithRetryInDirectory,
resolveInheritedPromptTools,
} from "../../shared"
import {
clearDelegatedChildSessionBootstrap,
registerDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import {
hasMoreFallbacks,
shouldRetryError,
} from "../../shared/model-error-classifier"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { setSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
import { isInsideTmux } from "../../shared/tmux"
import {
shouldRetryError,
hasMoreFallbacks,
} from "../../shared/model-error-classifier"
import {
POLLING_INTERVAL_MS,
TASK_CLEANUP_DELAY_MS,
TASK_TTL_MS,
type QueueItem,
} from "./constants"
import { subagentSessions } from "../claude-code-session-state"
import { clearSessionAgent, setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state"
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { getTaskToastManager } from "../task-toast-manager"
import { formatDuration } from "./duration-formatter"
import {
buildBackgroundTaskNotificationText,
type BackgroundTaskNotificationTask,
} from "./background-task-notification-template"
import {
isAbortedSessionError,
extractErrorName,
extractErrorMessage,
extractErrorStatusCode,
getSessionErrorMessage,
isRecord,
} from "./error-classifier"
import { tryFallbackRetry } from "./fallback-retry-handler"
import { abortWithTimeout } from "./abort-with-timeout"
import {
bindAttemptSession,
ensureCurrentAttempt,
findAttemptBySession,
finalizeAttempt,
findAttemptBySession,
getCurrentAttempt,
startAttempt,
} from "./attempt-lifecycle"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle"
import { promptAsyncAfterSessionIdle, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate"
import {
type BackgroundTaskNotificationTask,
buildBackgroundTaskNotificationText,
} from "./background-task-notification-template"
import {
findNearestMessageExcludingCompaction,
resolvePromptContextFromSessionMessages,
} from "./compaction-aware-message-resolver"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller"
import { checkAndInterruptStaleTasks } from "./task-poller"
import { ConcurrencyManager } from "./concurrency"
import {
POLLING_INTERVAL_MS,
type QueueItem,
TASK_CLEANUP_DELAY_MS,
TASK_TTL_MS,
} from "./constants"
import { formatDuration } from "./duration-formatter"
import {
extractErrorMessage,
extractErrorName,
extractErrorStatusCode,
getSessionErrorMessage,
isAbortedSessionError,
isRecord,
} from "./error-classifier"
import { tryFallbackRetry } from "./fallback-retry-handler"
import {
type CircuitBreakerSettings,
detectRepetitiveToolUse,
recordToolCall,
resolveCircuitBreakerSettings,
} from "./loop-detector"
import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
import { abortWithTimeout } from "./abort-with-timeout"
import {
MIN_SESSION_GONE_POLLS,
verifySessionExists as verifySessionStillExists,
} from "./session-existence"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier"
import {
detectRepetitiveToolUse,
recordToolCall,
resolveCircuitBreakerSettings,
type CircuitBreakerSettings,
} from "./loop-detector"
import { buildFallbackBody, FALLBACK_AGENT, isAgentNotFoundError } from "./spawner"
import {
createSubagentDepthLimitError,
getMaxSubagentDepth,
resolveSubagentSpawnContext,
type SubagentSpawnContext,
} from "./subagent-spawn-limits"
import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier"
import { TaskHistory } from "./task-history"
import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller"
import {
archiveBackgroundTask,
forgetBackgroundTask,
getRegisteredBackgroundTask,
rememberBackgroundTask,
} from "./task-registry"
import type {
BackgroundTask,
BackgroundTaskAttempt,
LaunchInput,
ResumeInput,
} from "./types"
type OpencodeClient = PluginInput["client"]
type ResumeTaskSnapshot = {
@@ -111,6 +119,13 @@ type ResumeTaskSnapshot = {
concurrencyGroup?: string
}
const TERMINAL_BACKGROUND_TASK_STATUSES = new Set<BackgroundTask["status"]>([
"completed",
"error",
"cancelled",
"interrupt",
])
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100
const PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS = 5_000
@@ -376,6 +391,7 @@ export class BackgroundManager {
private addTask(task: BackgroundTask): void {
this.completedTaskArchive.delete(task.id)
this.tasks.set(task.id, task)
rememberBackgroundTask(task)
if (!task.parentSessionId) {
return
}
@@ -387,6 +403,7 @@ export class BackgroundManager {
private removeTask(task: BackgroundTask): void {
this.archiveCompletedTask(task)
archiveBackgroundTask(task)
this.tasks.delete(task.id)
this.removeTaskFromParentIndex(task.id, task.parentSessionId)
}
@@ -557,6 +574,8 @@ export class BackgroundManager {
parentTools: input.parentTools,
model: input.model,
fallbackChain: input.fallbackChain,
skillContent: input.skillContent,
sessionPermission: input.sessionPermission,
attemptCount: 0,
category: input.category,
onSessionCreated: input.onSessionCreated,
@@ -659,6 +678,7 @@ export class BackgroundManager {
// Abort the orphaned session if one was created before the error
if (item.task.sessionId) {
clearDelegatedChildSessionBootstrap(item.task.sessionId)
await this.abortSessionWithLogging(item.task.sessionId, "startTask error cleanup")
}
@@ -729,6 +749,7 @@ export class BackgroundManager {
const sessionID = createResult.data.id
if (task.status === "cancelled") {
clearDelegatedChildSessionBootstrap(sessionID)
await this.abortSessionWithLogging(sessionID, "cancelled pre-start cleanup")
this.concurrencyManager.release(concurrencyKey)
return
@@ -737,8 +758,11 @@ export class BackgroundManager {
await input.onSessionCreated?.(sessionID)
this.settlePreStartDescendantReservation(task)
subagentSessions.add(sessionID)
setSessionAgent(sessionID, input.agent)
if (this.tasks.get(task.id)?.status === "cancelled") {
clearDelegatedChildSessionBootstrap(sessionID)
clearSessionAgent(sessionID)
await this.abortSessionWithLogging(sessionID, "cancelled during launch setup")
subagentSessions.delete(sessionID)
if (task.rootSessionId) {
@@ -750,6 +774,8 @@ export class BackgroundManager {
const boundAttempt = bindAttemptSession(task, attemptID, sessionID, input.model)
if (!boundAttempt) {
clearDelegatedChildSessionBootstrap(sessionID)
clearSessionAgent(sessionID)
await this.abortSessionWithLogging(sessionID, "stale attempt binding cleanup")
subagentSessions.delete(sessionID)
if (task.rootSessionId) {
@@ -805,21 +831,6 @@ The fallback retry session is now created and can be inspected directly.
this.taskHistory.record(input.parentSessionId, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt })
this.startPolling()
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.updateTask(task.id, "running")
}
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
sessionID,
agent: input.agent,
model: input.model,
hasSkillContent: !!input.skillContent,
promptLength: input.prompt.length,
})
// Fire-and-forget prompt via promptAsync (no response body needed)
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
// Temperature/topP and provider-specific options are applied through chat.params.
@@ -835,23 +846,46 @@ The fallback retry session is now created and can be inspected directly.
applySessionPromptParams(sessionID, input.model)
}
const launchTools = {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent, {
includeTeamToolDenylist: input.teamRunId === undefined,
}),
}
setSessionTools(sessionID, launchTools)
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
registerDelegatedChildSessionBootstrap({
sessionID,
promptText: input.prompt,
fallbackChain: input.fallbackChain,
category: input.category,
system: input.skillContent,
tools: launchTools,
modelFallbackControllerAccessor: this.modelFallbackControllerAccessor,
})
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.updateTask(task.id, "running")
}
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
sessionID,
agent: input.agent,
model: input.model,
hasSkillContent: !!input.skillContent,
promptLength: input.prompt.length,
})
const promptBody = {
agent: input.agent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: (() => {
const tools = {
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent, {
includeTeamToolDenylist: input.teamRunId === undefined,
}),
}
setSessionTools(sessionID, tools)
return tools
})(),
tools: launchTools,
parts: [createInternalAgentTextPart(input.prompt)],
}
@@ -870,7 +904,18 @@ The fallback retry session is now created and can be inspected directly.
const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, {
includeTeamToolDenylist: input.teamRunId === undefined,
})
setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>)
const fallbackTools = fallbackBody.tools as Record<string, boolean>
setSessionTools(sessionID, fallbackTools)
updateSessionAgent(sessionID, FALLBACK_AGENT)
registerDelegatedChildSessionBootstrap({
sessionID,
promptText: input.prompt,
fallbackChain: input.fallbackChain,
category: input.category,
system: input.skillContent,
tools: fallbackTools,
modelFallbackControllerAccessor: this.modelFallbackControllerAccessor,
})
await promptWithRetryInDirectory(this.client, {
path: { id: sessionID },
body: fallbackBody,
@@ -926,6 +971,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)
clearDelegatedChildSessionBootstrap(sessionID)
await this.abortSessionWithLogging(sessionID, "launch error cleanup")
this.markForNotification(existingTask)
@@ -960,7 +1006,7 @@ The fallback retry session is now created and can be inspected directly.
}
getTask(id: string): BackgroundTask | undefined {
return this.tasks.get(id) ?? this.completedTaskArchive.get(id)
return this.tasks.get(id) ?? this.completedTaskArchive.get(id) ?? getRegisteredBackgroundTask(id)
}
getTasksByParentSession(sessionID: string): BackgroundTask[] {
@@ -1313,6 +1359,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)
if (existingTask.sessionId) {
clearDelegatedChildSessionBootstrap(existingTask.sessionId)
await this.abortSessionWithLogging(existingTask.sessionId, "resume error cleanup")
}
@@ -1650,6 +1697,7 @@ The fallback retry session is now created and can be inspected directly.
}
this.rootDescendantCounts.delete(sessionID)
clearDelegatedChildSessionBootstrap(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
@@ -1732,6 +1780,7 @@ The fallback retry session is now created and can be inspected directly.
this.scheduleTaskRemoval(task.id)
if (task.sessionId) {
clearDelegatedChildSessionBootstrap(task.sessionId)
SessionCategoryRegistry.remove(task.sessionId)
await this.abortSessionWithLogging(task.sessionId, `${reason} cleanup`)
}
@@ -1838,6 +1887,7 @@ The fallback retry session is now created and can be inspected directly.
}
this.scheduleTaskRemoval(task.id)
if (task.sessionId) {
clearDelegatedChildSessionBootstrap(task.sessionId)
SessionCategoryRegistry.remove(task.sessionId)
}
@@ -1895,6 +1945,7 @@ The task was re-queued on a fallback model after a retryable failure.
if (retried && previousSessionID) {
this.clearSessionOutputObserved(previousSessionID)
this.clearSessionTodoObservation(previousSessionID)
clearDelegatedChildSessionBootstrap(previousSessionID)
subagentSessions.delete(previousSessionID)
}
return retried
@@ -2059,6 +2110,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)
clearDelegatedChildSessionBootstrap(task.sessionId)
SessionCategoryRegistry.remove(task.sessionId)
}
log("[background-agent] Removed completed task from memory:", taskId)
@@ -2134,6 +2186,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})`)
clearDelegatedChildSessionBootstrap(task.sessionId)
SessionCategoryRegistry.remove(task.sessionId)
}
@@ -2259,6 +2312,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})`)
clearDelegatedChildSessionBootstrap(task.sessionId)
SessionCategoryRegistry.remove(task.sessionId)
}
@@ -2588,6 +2642,7 @@ The task was re-queued on a fallback model after a retryable failure.
removeTaskToastTracking(task.id)
this.scheduleTaskRemoval(task.id)
if (task.sessionId) {
clearDelegatedChildSessionBootstrap(task.sessionId)
SessionCategoryRegistry.remove(task.sessionId)
}
@@ -2775,6 +2830,12 @@ The task was re-queued on a fallback model after a retryable failure.
// Release concurrency for all running tasks
for (const task of this.tasks.values()) {
if (TERMINAL_BACKGROUND_TASK_STATUSES.has(task.status)) {
archiveBackgroundTask(task)
} else {
forgetBackgroundTask(task.id)
}
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
@@ -2795,6 +2856,7 @@ The task was re-queued on a fallback model after a retryable failure.
for (const sessionID of trackedSessionIDs) {
subagentSessions.delete(sessionID)
clearDelegatedChildSessionBootstrap(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
+64 -3
View File
@@ -1,10 +1,10 @@
import { describe, test, expect, mock, afterEach } from "bun:test"
import { createTask, startTask } from "./spawner"
import type { BackgroundTask } from "./types"
import { afterEach, describe, expect, mock, test } from "bun:test"
import {
clearSessionPromptParams,
getSessionPromptParams,
} from "../../shared/session-prompt-params-state"
import { createTask, startTask } from "./spawner"
import type { BackgroundTask } from "./types"
describe("background-agent spawner agent-not-found fallback", () => {
afterEach(() => {
@@ -694,6 +694,67 @@ describe("background-agent spawner fallback model promotion", () => {
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0]?.body?.agent).toBe("Hephaestus - Deep Agent")
})
test("persists the same normalized agent used by promptAsync into session-agent state (GH-3259 follow-up)", async () => {
//#given - ZWSP+sort-prefix wrapped agent name
const promptCalls: Array<{ body?: { agent?: string } }> = []
const sessionID = "ses_child_normalized"
const wrappedAgent = "\u200B\u200B5|Hephaestus - Deep Agent"
const client = {
session: {
get: async () => ({ data: { directory: "/parent/dir" } }),
create: async () => ({ data: { id: sessionID } }),
promptAsync: async (args?: { body?: { agent?: string } }) => {
promptCalls.push(args ?? {})
return {}
},
},
}
const { _resetForTesting: resetState, getSessionAgent } = await import("../claude-code-session-state")
resetState()
const task = createTask({
description: "Normalized agent storage",
prompt: "Do work",
agent: wrappedAgent,
parentSessionId: "ses_parent",
parentMessageId: "msg_parent",
})
const item = {
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionId: task.parentSessionId,
parentMessageId: task.parentMessageId,
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
},
}
const ctx = {
client,
directory: "/fallback",
concurrencyManager: { release: () => {} },
tmuxEnabled: false,
onTaskError: () => {},
}
//#when
await startTask(item as never, ctx as never)
await new Promise((resolve) => setTimeout(resolve, 0))
//#then
expect(promptCalls).toHaveLength(1)
const dispatchedAgent = promptCalls[0]?.body?.agent
expect(dispatchedAgent).toBe("Hephaestus - Deep Agent")
expect(getSessionAgent(sessionID)).toBe(dispatchedAgent)
})
})
describe("background-agent spawner tmux callback ordering", () => {
+33 -15
View File
@@ -1,13 +1,14 @@
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared"
import { createInternalAgentTextPart, getAgentToolRestrictions, log, promptWithRetryInDirectory } from "../../shared"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { releasePromptAsyncReservation } from "../../shared/prompt-async-gate"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import { setSessionTools } from "../../shared/session-tools-store"
import { isInsideTmux } from "../../shared/tmux"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import type { ConcurrencyManager } from "./concurrency"
import type { OnSubagentSessionCreated, OpencodeClient, QueueItem } from "./constants"
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
export const FALLBACK_AGENT = "general"
@@ -65,7 +66,13 @@ export function createTask(input: LaunchInput): BackgroundTask {
teamRunId: input.teamRunId,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
fallbackChain: input.fallbackChain,
skillContent: input.skillContent,
sessionPermission: input.sessionPermission,
category: input.category,
isUnstableAgent: input.isUnstableAgent,
onSessionCreated: input.onSessionCreated,
}
}
@@ -116,8 +123,10 @@ export async function startTask(
}
const sessionID = createResult.data.id
const normalizedAgent = stripAgentListSortPrefix(input.agent)
await input.onSessionCreated?.(sessionID)
subagentSessions.add(sessionID)
setSessionAgent(sessionID, normalizedAgent)
task.status = "running"
task.startedAt = new Date()
@@ -129,7 +138,7 @@ export async function startTask(
task.concurrencyKey = concurrencyKey
task.concurrencyGroup = concurrencyKey
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: normalizedAgent })
const toastManager = getTaskToastManager()
if (toastManager) {
@@ -138,7 +147,7 @@ export async function startTask(
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
sessionID,
agent: input.agent,
agent: normalizedAgent,
model: input.model,
hasSkillContent: !!input.skillContent,
promptLength: input.prompt.length,
@@ -151,7 +160,6 @@ export async function startTask(
}
: undefined
const launchVariant = input.model?.variant
const normalizedAgent = stripAgentListSortPrefix(input.agent)
applySessionPromptParams(sessionID, input.model)
@@ -170,6 +178,7 @@ export async function startTask(
},
parts: [createInternalAgentTextPart(input.prompt)],
}
setSessionTools(sessionID, promptBody.tools)
// Must fire BEFORE tmux callback: attach client needs session activity to render TUI.
const promptChain = promptWithRetryInDirectory(client, {
@@ -184,11 +193,15 @@ export async function startTask(
})
try {
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, {
includeTeamToolDenylist: input.teamRunId === undefined,
})
const fallbackTools = fallbackBody.tools as Record<string, boolean>
setSessionTools(sessionID, fallbackTools)
updateSessionAgent(sessionID, FALLBACK_AGENT)
await promptWithRetryInDirectory(client, {
path: { id: sessionID },
body: buildFallbackBody(promptBody, FALLBACK_AGENT, {
includeTeamToolDenylist: input.teamRunId === undefined,
}),
body: fallbackBody,
}, parentDirectory)
task.agent = FALLBACK_AGENT
return
@@ -310,6 +323,7 @@ export async function resumeTask(
},
parts: [createInternalAgentTextPart(input.prompt)],
}
setSessionTools(sessionID, resumeBody.tools)
promptWithRetryInDirectory(client, {
path: { id: sessionID },
@@ -323,11 +337,15 @@ export async function resumeTask(
})
try {
releasePromptAsyncReservation(sessionID, "model-suggestion-retry")
const fallbackBody = buildFallbackBody(resumeBody, FALLBACK_AGENT, {
includeTeamToolDenylist: task.teamRunId === undefined,
})
const fallbackTools = fallbackBody.tools as Record<string, boolean>
setSessionTools(sessionID, fallbackTools)
updateSessionAgent(sessionID, FALLBACK_AGENT)
await promptWithRetryInDirectory(client, {
path: { id: sessionID },
body: buildFallbackBody(resumeBody, FALLBACK_AGENT, {
includeTeamToolDenylist: task.teamRunId === undefined,
}),
body: fallbackBody,
}, directory)
task.agent = FALLBACK_AGENT
return
@@ -0,0 +1,137 @@
import type { BackgroundTask } from "./types"
const MAX_COMPLETED_TASK_REGISTRY_SIZE = 100
const REGISTRY_KEY = "__omoBackgroundTaskRegistry"
type BackgroundTaskRegistry = {
activeTasks: Map<string, () => BackgroundTask>
completedTasks: Map<string, BackgroundTask>
}
type GlobalWithBackgroundTaskRegistry = typeof globalThis & {
[REGISTRY_KEY]?: BackgroundTaskRegistry
}
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
"completed",
"error",
"cancelled",
"interrupt",
])
function getRegistry(): BackgroundTaskRegistry {
const registryGlobal = globalThis as GlobalWithBackgroundTaskRegistry
registryGlobal[REGISTRY_KEY] ??= {
activeTasks: new Map<string, () => BackgroundTask>(),
completedTasks: new Map<string, BackgroundTask>(),
}
const registry = registryGlobal[REGISTRY_KEY]
return registry
}
function cloneProgress(progress: BackgroundTask["progress"]): BackgroundTask["progress"] {
if (!progress) {
return undefined
}
return {
...progress,
countedToolPartIDs: progress.countedToolPartIDs ? new Set(progress.countedToolPartIDs) : undefined,
}
}
function cloneAttempts(attempts: BackgroundTask["attempts"]): BackgroundTask["attempts"] {
if (!attempts) {
return undefined
}
return attempts.map((attempt) => ({ ...attempt }))
}
function cloneRegisteredTask(task: BackgroundTask): BackgroundTask {
return {
id: task.id,
rootSessionId: task.rootSessionId,
parentSessionId: task.parentSessionId,
parentMessageId: task.parentMessageId,
teamRunId: task.teamRunId,
description: task.description,
prompt: "[redacted]",
agent: task.agent,
spawnDepth: task.spawnDepth,
sessionId: task.sessionId,
status: task.status,
queuedAt: task.queuedAt,
startedAt: task.startedAt,
completedAt: task.completedAt,
result: task.result,
progress: cloneProgress(task.progress),
parentModel: task.parentModel,
model: task.model,
fallbackChain: task.fallbackChain,
attemptCount: task.attemptCount,
concurrencyKey: task.concurrencyKey,
concurrencyGroup: task.concurrencyGroup,
parentAgent: task.parentAgent,
parentTools: task.parentTools,
isUnstableAgent: task.isUnstableAgent,
error: task.error,
category: task.category,
retryNotification: task.retryNotification ? { ...task.retryNotification } : undefined,
attempts: cloneAttempts(task.attempts),
currentAttemptID: task.currentAttemptID,
lastMsgCount: task.lastMsgCount,
stablePolls: task.stablePolls,
consecutiveMissedPolls: task.consecutiveMissedPolls,
}
}
function trimCompletedTasks(registry: BackgroundTaskRegistry): void {
while (registry.completedTasks.size > MAX_COMPLETED_TASK_REGISTRY_SIZE) {
const oldestTaskID = registry.completedTasks.keys().next().value
if (typeof oldestTaskID !== "string") {
return
}
registry.completedTasks.delete(oldestTaskID)
}
}
export function rememberBackgroundTask(task: BackgroundTask): void {
const registry = getRegistry()
registry.completedTasks.delete(task.id)
registry.activeTasks.set(task.id, () => cloneRegisteredTask(task))
}
export function archiveBackgroundTask(task: BackgroundTask): void {
const registry = getRegistry()
registry.activeTasks.delete(task.id)
registry.completedTasks.delete(task.id)
if (!task.sessionId || !TERMINAL_TASK_STATUSES.has(task.status)) {
return
}
registry.completedTasks.set(task.id, cloneRegisteredTask(task))
trimCompletedTasks(registry)
}
export function getRegisteredBackgroundTask(taskID: string): BackgroundTask | undefined {
const registry = getRegistry()
const activeTask = registry.activeTasks.get(taskID)
if (activeTask) {
return activeTask()
}
const completedTask = registry.completedTasks.get(taskID)
return completedTask ? cloneRegisteredTask(completedTask) : undefined
}
export function forgetBackgroundTask(taskID: string): void {
const registry = getRegistry()
registry.activeTasks.delete(taskID)
registry.completedTasks.delete(taskID)
}
export function clearBackgroundTaskRegistryForTesting(): void {
const registry = getRegistry()
registry.activeTasks.clear()
registry.completedTasks.clear()
}
+2
View File
@@ -73,6 +73,8 @@ export interface BackgroundTask {
parentAgent?: string
/** Parent session's tool restrictions for notification prompts */
parentTools?: Record<string, boolean>
skillContent?: string
sessionPermission?: SessionPermissionRule[]
/** Marks if the task was launched from an unstable agent/category */
isUnstableAgent?: boolean
/** Category used for this task (e.g., 'quick', 'visual-engineering') */