merge(dev): resolve background-agent delegated fallback conflicts

Reconcile the latest dev branch changes with the delegated child-session fallback work. Preserve the upstream background-agent updates while keeping the delegated bootstrap cleanup and compatibility wiring fixes intact, then re-verify the affected regression suites and typecheck.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
tw-yshuang
2026-05-11 03:02:14 +08:00
668 changed files with 44608 additions and 6160 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# src/features/background-agent/ — Core Orchestration Engine
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
@@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
const sharedLogMock = mock(() => {})
const readConnectedProvidersCacheMock = mock(() => null)
const readProviderModelsCacheMock = mock(() => null)
const readProviderModelsCacheMock = mock((): { connected: string[] } | null => null)
const shouldRetryErrorMock = mock(() => true)
const getNextFallbackMock = mock((chain: Array<{ model: string }>, attempt: number) => chain[attempt])
const hasMoreFallbacksMock = mock((chain: Array<{ model: string }>, attempt: number) => attempt < chain.length)
@@ -88,7 +88,7 @@ function createMockConcurrencyManager(): ConcurrencyManager {
acquire: mock(async () => {}),
getQueueLength: mock(() => 0),
getActiveCount: mock(() => 0),
} as unknown as ConcurrencyManager
} as never
}
function createMockClient(): {
@@ -101,7 +101,7 @@ function createMockClient(): {
session: {
abort: abortMock,
},
} as unknown as OpencodeClient,
} as never,
abortMock,
}
}
@@ -133,9 +133,9 @@ describe("tryFallbackRetry", () => {
})
beforeEach(() => {
;(shouldRetryError as any).mockImplementation(() => true)
;(selectFallbackProvider as any).mockImplementation((providers: string[]) => providers[0])
;(readProviderModelsCache as any).mockReturnValue(null)
shouldRetryError.mockImplementation(() => true)
selectFallbackProvider.mockImplementation((providers: string[]) => providers[0])
readProviderModelsCache.mockReturnValue(null)
})
describe("#given retryable error with fallback chain", () => {
@@ -260,6 +260,21 @@ describe("tryFallbackRetry", () => {
expect(args.processKey).toHaveBeenCalledWith(key)
})
test("preserves team identity and session callback in retry input", async () => {
const onSessionCreated = mock(async () => {})
const args = createDefaultArgs({
teamRunId: "team-run-1",
onSessionCreated,
})
await tryFallbackRetry(args)
const key = `${args.task.model!.providerID}/${args.task.model!.modelID}`
const retryInput = args.queuesByKey.get(key)?.[0]?.input
expect(retryInput?.teamRunId).toBe("team-run-1")
expect(retryInput?.onSessionCreated).toBe(onSessionCreated)
})
test("finalizes the failed attempt, creates a new pending attempt, and enqueues its explicit attemptID", async () => {
const args = createDefaultArgs({
status: "running",
@@ -308,13 +323,16 @@ describe("tryFallbackRetry", () => {
const key = `${args.task.model!.providerID}/${args.task.model!.modelID}`
const queue = args.queuesByKey.get(key)
expect(queue).toBeDefined()
expect((queue?.[0] as QueueItem & { attemptID?: string })?.attemptID).toBe(nextAttempt?.attemptId)
const queuedAttemptID = queue?.[0]?.attemptID
expect(queuedAttemptID).toBeDefined()
expect(nextAttempt?.attemptId).toBeDefined()
expect(queuedAttemptID).toBe(nextAttempt?.attemptId ?? "")
})
})
describe("#given non-retryable error", () => {
test("returns false when shouldRetryError returns false", async () => {
;(shouldRetryError as any).mockImplementation(() => false)
shouldRetryError.mockImplementation(() => false)
const args = createDefaultArgs()
const result = await tryFallbackRetry(args)
@@ -415,8 +433,8 @@ describe("tryFallbackRetry", () => {
describe("#given disconnected fallback providers with connected preferred provider", () => {
test("keeps fallback entry and selects connected preferred provider", async () => {
;(readProviderModelsCache as any).mockReturnValueOnce({ connected: ["provider-a"] })
;(selectFallbackProvider as any).mockImplementationOnce(
readProviderModelsCache.mockReturnValueOnce({ connected: ["provider-a"] })
selectFallbackProvider.mockImplementationOnce(
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
)
@@ -170,10 +170,12 @@ export async function tryFallbackRetry(args: {
parentModel: task.parentModel,
parentAgent: task.parentAgent,
parentTools: task.parentTools,
teamRunId: task.teamRunId,
model: nextModel,
fallbackChain: task.fallbackChain,
category: task.category,
isUnstableAgent: task.isUnstableAgent,
onSessionCreated: task.onSessionCreated,
}
if (previousSessionID) {
@@ -23,7 +23,7 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager {
tasks: Map<string, BackgroundTask>
}
testManager.enqueueNotificationForParent = async (_sessionId: sessionID, fn) => {
testManager.enqueueNotificationForParent = async (_sessionId: string, fn) => {
await fn()
}
testManager.notifyParentSession = async () => {}
@@ -0,0 +1,115 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
import { MIN_SESSION_GONE_POLLS } from "./session-existence"
import type { BackgroundTask } from "./types"
type SessionStatus = { type: string }
type SessionStatusResponse = { data: Record<string, SessionStatus> }
type SessionOverrides = {
status?: (() => Promise<SessionStatusResponse>) | undefined
abort?: () => Promise<object>
}
function createRunningTask(sessionId: string): BackgroundTask {
return {
id: `bg_test_${sessionId}`,
sessionId,
parentSessionId: "parent-session",
parentMessageId: "parent-message",
description: "test task",
prompt: "test prompt",
agent: "explore",
status: "running",
startedAt: new Date(),
progress: { toolCalls: 0, lastUpdate: new Date() },
}
}
function createManager(overrides: SessionOverrides): BackgroundManager {
const session = {
...(overrides.status === undefined ? {} : { status: overrides.status }),
get: async () => ({ data: { id: "session" } }),
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: overrides.abort ?? (async () => ({})),
todo: async () => ({ data: [] }),
messages: async () => ({
data: [{
info: { role: "assistant", finish: "end_turn", id: "message-2" },
parts: [{ type: "text", text: "done" }],
}],
}),
}
const client = { session }
return new BackgroundManager({
pluginContext: { client, directory: tmpdir() } as PluginInput,
enableParentSessionNotifications: false,
})
}
async function poll(manager: BackgroundManager, cycles: number): Promise<void> {
for (let count = 0; count < cycles; count += 1) {
await manager["pollRunningTasks"]()
}
}
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
manager["tasks"].set(task.id, task)
}
describe("BackgroundManager pollRunningTasks when session status registry is unavailable", () => {
test("keeps running tasks active and does not increment missed polls when status is unavailable or throws", async () => {
const cases: Array<{ name: string; status?: () => Promise<SessionStatusResponse> }> = [
{ name: "missing status method" },
{ name: "throwing status method", status: async () => { throw new Error("status unavailable") } },
]
for (const testCase of cases) {
// given
let abortCallCount = 0
const manager = createManager({
status: testCase.status,
abort: async () => {
abortCallCount += 1
return {}
},
})
const task = createRunningTask(`ses-${testCase.name.replaceAll(" ", "-")}`)
injectTask(manager, task)
// when
await poll(manager, MIN_SESSION_GONE_POLLS + 1)
// then
expect(task.status).toBe("running")
expect(task.completedAt).toBeUndefined()
expect(task.error).toBeUndefined()
expect(task.consecutiveMissedPolls ?? 0).toBe(0)
expect(abortCallCount).toBe(0)
await manager.shutdown()
}
})
test("completes a task when a reliable status response omits the session", async () => {
// given
const manager = createManager({
status: async () => ({ data: {} }),
})
const task = createRunningTask("ses-gone-after-reliable-status")
injectTask(manager, task)
// when
await poll(manager, MIN_SESSION_GONE_POLLS)
await manager.shutdown()
// then
expect(task.status).toBe("completed")
expect(task.completedAt).toBeDefined()
})
})
@@ -4,8 +4,25 @@ import { describe, test, expect, mock } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
import { MIN_SESSION_GONE_POLLS } from "./session-existence"
import type { BackgroundTask } from "./types"
function createPluginContext(client: object): PluginInput {
const directory = tmpdir()
return {
project: {
id: "test-project",
worktree: directory,
time: { created: Date.now() },
},
directory,
worktree: directory,
serverUrl: new URL("http://localhost:4096"),
$: {} as PluginInput["$"],
client: client as PluginInput["client"],
}
}
function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string, { type: string }> }>): BackgroundManager {
const client = {
session: {
@@ -18,7 +35,7 @@ function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string
},
}
return new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
return new BackgroundManager({ pluginContext: createPluginContext(client) })
}
describe("BackgroundManager polling overlap", () => {
@@ -42,9 +59,9 @@ describe("BackgroundManager polling overlap", () => {
})
//#when
const firstPoll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks()
const firstPoll = manager["pollRunningTasks"]()
await Promise.resolve()
const secondPoll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks()
const secondPoll = manager["pollRunningTasks"]()
releaseStatus?.()
await Promise.all([firstPoll, secondPoll])
manager.shutdown()
@@ -72,8 +89,7 @@ function createRunningTask(sessionId: string): BackgroundTask {
}
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
const tasks = (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
tasks.set(task.id, task)
manager["tasks"].set(task.id, task)
}
function createManagerWithClient(clientOverrides: Record<string, unknown> = {}): BackgroundManager {
@@ -98,7 +114,7 @@ function createManagerWithClient(clientOverrides: Record<string, unknown> = {}):
},
}
return new BackgroundManager(
{ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: undefined, enableParentSessionNotifications: false },
{ pluginContext: createPluginContext(client), config: undefined, enableParentSessionNotifications: false },
)
}
@@ -151,7 +167,7 @@ describe("BackgroundManager pollRunningTasks", () => {
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
const poll = manager["pollRunningTasks"]
await poll.call(manager)
manager.shutdown()
@@ -184,6 +200,62 @@ describe("BackgroundManager pollRunningTasks", () => {
expect(task.consecutiveMissedPolls).toBe(1)
expect(getSession).not.toHaveBeenCalled()
})
test("#when status polling is unavailable #then it does not complete or increment missed polls", async () => {
const cases: Array<{ name: string; status?: (() => Promise<{ data: Record<string, { type: string }> }>) | undefined }> = [
{ name: "missing status method", status: undefined },
{ name: "throwing status method", status: async () => { throw new Error("status unavailable") } },
]
for (const testCase of cases) {
//#given
let abortCallCount = 0
const manager = createManagerWithClient({
status: testCase.status,
abort: async () => {
abortCallCount += 1
return {}
},
})
const task = createRunningTask(`ses-${testCase.name.replace(/ /g, "-")}`)
injectTask(manager, task)
//#when
const poll = manager["pollRunningTasks"]
for (let count = 0; count < MIN_SESSION_GONE_POLLS + 1; count += 1) {
await poll.call(manager)
}
//#then
expect(task.status).toBe("running")
expect(task.completedAt).toBeUndefined()
expect(task.error).toBeUndefined()
expect(task.consecutiveMissedPolls ?? 0).toBe(0)
expect(abortCallCount).toBe(0)
await manager.shutdown()
}
})
test("#when reliable status polling omits the session #then it completes through the session-gone path", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: {} }),
})
const task = createRunningTask("ses-reliably-gone")
injectTask(manager, task)
//#when
const poll = manager["pollRunningTasks"]
for (let count = 0; count < MIN_SESSION_GONE_POLLS; count += 1) {
await poll.call(manager)
}
await manager.shutdown()
//#then
expect(task.status).toBe("completed")
expect(task.completedAt).toBeDefined()
})
})
describe("#given a running task whose session status is idle", () => {
@@ -196,7 +268,7 @@ describe("BackgroundManager pollRunningTasks", () => {
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
const poll = manager["pollRunningTasks"]
await poll.call(manager)
manager.shutdown()
@@ -228,7 +300,7 @@ describe("BackgroundManager pollRunningTasks", () => {
})
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
const poll = manager["pollRunningTasks"]
await poll.call(manager)
manager.shutdown()
@@ -265,7 +337,7 @@ describe("BackgroundManager pollRunningTasks", () => {
})
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
const poll = manager["pollRunningTasks"]
await poll.call(manager)
manager.shutdown()
@@ -285,13 +357,36 @@ describe("BackgroundManager pollRunningTasks", () => {
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
const poll = manager["pollRunningTasks"]
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("running")
})
test("#when progress is older than prune TTL #then active status still keeps the task running", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-busy-stale": { type: "busy" } } }),
})
const task = createRunningTask("ses-busy-stale")
task.startedAt = new Date(Date.now() - 60 * 60 * 1000)
task.progress = {
toolCalls: 4,
lastUpdate: new Date(Date.now() - 35 * 60 * 1000),
}
injectTask(manager, task)
//#when
const poll = manager["pollRunningTasks"]
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("running")
expect(task.error).toBeUndefined()
})
})
describe("#given a running task whose session has terminal non-idle status", () => {
@@ -304,7 +399,7 @@ describe("BackgroundManager pollRunningTasks", () => {
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
const poll = manager["pollRunningTasks"]
await poll.call(manager)
manager.shutdown()
@@ -322,7 +417,7 @@ describe("BackgroundManager pollRunningTasks", () => {
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
const poll = manager["pollRunningTasks"]
await poll.call(manager)
manager.shutdown()
File diff suppressed because it is too large Load Diff
+216 -29
View File
@@ -59,6 +59,7 @@ import {
startAttempt,
} from "./attempt-lifecycle"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
import {
findNearestMessageExcludingCompaction,
resolvePromptContextFromSessionMessages,
@@ -66,7 +67,7 @@ import {
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
import { pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller"
import { checkAndInterruptStaleTasks } from "./task-poller"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
import { abortWithTimeout } from "./abort-with-timeout"
@@ -91,9 +92,24 @@ import {
clearDelegatedChildSessionBootstrap,
registerDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
import { settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
type OpencodeClient = PluginInput["client"]
type ParentWakePromptContext = {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
tools?: Record<string, boolean>
}
type SessionStatusInfo = { type?: string }
const BACKGROUND_PARENT_WAKE_PROMPT = `<system-reminder>
[BACKGROUND TASK NOTIFICATION READY]
A background task notification was already added to this session. Continue from that notification.
</system-reminder>`
interface MessagePartInfo {
id?: string
sessionID?: string
@@ -185,6 +201,7 @@ export interface BackgroundManagerConfig {
onShutdown?: () => void | Promise<void>
enableParentSessionNotifications?: boolean
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
log?: typeof log
}
export class BackgroundManager {
@@ -212,12 +229,15 @@ export class BackgroundManager {
private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private pendingParentWakes: Map<string, ParentWakePromptContext> = new Map()
private observedOutputSessions: Set<string> = new Set()
private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
private rootDescendantCounts: Map<string, number>
private preStartDescendantReservations: Set<string>
private enableParentSessionNotifications: boolean
private modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
private logger: typeof log
private loggedSessionStatusUnavailable = false
readonly taskHistory = new TaskHistory()
private cachedCircuitBreakerSettings?: CircuitBreakerSettings
@@ -239,6 +259,7 @@ export class BackgroundManager {
this.preStartDescendantReservations = new Set()
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
this.modelFallbackControllerAccessor = options?.modelFallbackControllerAccessor
this.logger = options?.log ?? log
this.registerProcessCleanup()
}
@@ -391,6 +412,12 @@ export class BackgroundManager {
throw new Error("Agent parameter is required")
}
input = { ...input, agent: input.agent.trim().replace(/^[\\/"']+|[\\/"']+$/g, "").trim() }
if (!input.agent) {
throw new Error("Agent parameter is required after sanitization")
}
const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionId)
try {
@@ -415,6 +442,7 @@ export class BackgroundManager {
spawnDepth: spawnReservation.spawnContext.childDepth,
parentSessionId: input.parentSessionId,
parentMessageId: input.parentMessageId,
teamRunId: input.teamRunId,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
@@ -422,6 +450,7 @@ export class BackgroundManager {
fallbackChain: input.fallbackChain,
attemptCount: 0,
category: input.category,
onSessionCreated: input.onSessionCreated,
}
const firstAttempt = startAttempt(task, input.model)
@@ -458,6 +487,9 @@ export class BackgroundManager {
spawnReservation.commit()
this.markPreStartDescendantReservation(task)
// Signal CLI run mode that background tasks are active
this.updateBackgroundTaskMarker(input.parentSessionId)
// Trigger processing (fire-and-forget)
void this.processKey(key)
@@ -521,6 +553,9 @@ export class BackgroundManager {
await this.abortSessionWithLogging(item.task.sessionId, "startTask error cleanup")
}
// Update continuation marker for CLI run mode
this.updateBackgroundTaskMarker(item.task.parentSessionId)
this.markForNotification(item.task)
this.enqueueNotificationForParent(item.task.parentSessionId, () => this.notifyParentSession(item.task)).catch(err => {
log("[background-agent] Failed to notify on startTask error:", err)
@@ -581,6 +616,7 @@ export class BackgroundManager {
return
}
await input.onSessionCreated?.(sessionID)
this.settlePreStartDescendantReservation(task)
subagentSessions.add(sessionID)
@@ -592,7 +628,7 @@ export class BackgroundManager {
parentID: input.parentSessionId,
})
if (this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) {
if (!input.suppressTmuxSpawn && this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) {
log("[background-agent] Invoking tmux callback NOW", { sessionID })
await this.onSubagentSessionCreated({
sessionID,
@@ -604,7 +640,9 @@ export class BackgroundManager {
log("[background-agent] tmux callback completed, waiting 200ms")
await new Promise(r => setTimeout(r, 200))
} else {
log("[background-agent] SKIP tmux callback - conditions not met")
log("[background-agent] SKIP tmux callback - conditions not met", {
suppressTmuxSpawn: !!input.suppressTmuxSpawn,
})
}
if (this.tasks.get(task.id)?.status === "cancelled") {
@@ -719,7 +757,9 @@ The fallback retry session is now created and can be inspected directly.
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agent),
...getAgentToolRestrictions(input.agent, {
includeTeamToolDenylist: input.teamRunId === undefined,
}),
}
setSessionTools(sessionID, tools)
return tools
@@ -739,7 +779,9 @@ The fallback retry session is now created and can be inspected directly.
taskId: task.id,
})
try {
const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT)
const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, {
includeTeamToolDenylist: input.teamRunId === undefined,
})
setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>)
await promptWithModelSuggestionRetry(this.client, {
path: { id: sessionID },
@@ -832,6 +874,21 @@ The fallback retry session is now created and can be inspected directly.
return tasks
}
private updateBackgroundTaskMarker(parentSessionID: string): void {
const tasks = this.getTasksByParentSession(parentSessionID)
const activeTasks = tasks.filter(t => t.status === "running" || t.status === "pending")
if (activeTasks.length > 0) {
setContinuationMarkerSource(
this.directory, parentSessionID, "background-task", "active",
`${activeTasks.length} background task(s) active`,
)
} else {
setContinuationMarkerSource(
this.directory, parentSessionID, "background-task", "idle",
)
}
}
getAllDescendantTasks(sessionID: string): BackgroundTask[] {
const result: BackgroundTask[] = []
const directChildren = this.getTasksByParentSession(sessionID)
@@ -1086,7 +1143,9 @@ The fallback retry session is now created and can be inspected directly.
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(existingTask.agent),
...getAgentToolRestrictions(existingTask.agent, {
includeTeamToolDenylist: existingTask.teamRunId === undefined,
}),
}
setSessionTools(existingTask.sessionId!, tools)
return tools
@@ -1336,6 +1395,12 @@ The fallback retry session is now created and can be inspected directly.
if (event.type === "session.idle") {
if (!props || typeof props !== "object") return
const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined
if (sessionID) {
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to flush pending parent wake:", { sessionID, error })
})
}
handleSessionIdleBackgroundEvent({
properties: props as Record<string, unknown>,
findBySession: (id) => {
@@ -1503,6 +1568,19 @@ The fallback retry session is now created and can be inspected directly.
canRetry,
})
const sessionId = task.sessionId
if (sessionId) {
const sessionStillAlive = await this.verifySessionExists(sessionId)
if (sessionStillAlive) {
this.logger("[background-agent] session.error received but session still alive, treating as transient:", {
taskId: task.id,
sessionId,
errorMessage: errorMsg?.slice(0, 200),
})
return
}
}
if (task.currentAttemptID) {
finalizeAttempt(task, task.currentAttemptID, "error", errorMsg)
} else {
@@ -1543,13 +1621,18 @@ The fallback retry session is now created and can be inspected directly.
this.cleanupDelegatedSessionContext(task.sessionId)
}
// Update continuation marker for CLI run mode
if (task.parentSessionId) {
this.updateBackgroundTaskMarker(task.parentSessionId)
}
this.markForNotification(task)
this.enqueueNotificationForParent(task.parentSessionId, () => this.notifyParentSession(task)).catch(err => {
log("[background-agent] Error in notifyParentSession for errored task:", { taskId: task.id, error: err })
})
}
private tryFallbackRetry(
private async tryFallbackRetry(
task: BackgroundTask,
errorInfo: { name?: string; message?: string },
source: string,
@@ -1585,15 +1668,14 @@ The task was re-queued on a fallback model after a retryable failure.
)
},
})
return result.then((retried) => {
if (retried && previousSessionID) {
this.clearSessionOutputObserved(previousSessionID)
this.clearSessionTodoObservation(previousSessionID)
subagentSessions.delete(previousSessionID)
this.cleanupDelegatedSessionContext(previousSessionID)
}
return retried
})
const retried = await result
if (retried && previousSessionID) {
this.clearSessionOutputObserved(previousSessionID)
this.clearSessionTodoObservation(previousSessionID)
subagentSessions.delete(previousSessionID)
this.cleanupDelegatedSessionContext(previousSessionID)
}
return retried
}
markForNotification(task: BackgroundTask): void {
@@ -1843,6 +1925,11 @@ The task was re-queued on a fallback model after a retryable failure.
removeTaskToastTracking(task.id)
// Update continuation marker for CLI run mode
if (task.parentSessionId) {
this.updateBackgroundTaskMarker(task.parentSessionId)
}
if (options?.skipNotification) {
this.cleanupPendingByParent(task)
this.scheduleTaskRemoval(task.id)
@@ -1961,6 +2048,11 @@ The task was re-queued on a fallback model after a retryable failure.
this.cleanupDelegatedSessionContext(task.sessionId)
}
// Update continuation marker for CLI run mode
if (task.parentSessionId) {
this.updateBackgroundTaskMarker(task.parentSessionId)
}
try {
await this.enqueueNotificationForParent(task.parentSessionId, () => this.notifyParentSession(task))
log(`[background-agent] Task completed via ${source}:`, task.id)
@@ -2102,24 +2194,32 @@ The task was re-queued on a fallback model after a retryable failure.
const shouldReply = allComplete || isTaskFailure
const variant = promptContext?.model?.variant
const parentPromptContext: ParentWakePromptContext = {
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(variant !== undefined ? { variant } : {}),
...(resolvedTools ? { tools: resolvedTools } : {}),
}
const shouldDeferReply = shouldReply && await this.isSessionActive(task.parentSessionId)
try {
await this.client.session.promptAsync({
path: { id: task.parentSessionId },
body: {
noReply: !shouldReply,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(variant !== undefined ? { variant } : {}),
...(resolvedTools ? { tools: resolvedTools } : {}),
noReply: shouldDeferReply || !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
})
if (shouldDeferReply) {
this.pendingParentWakes.set(task.parentSessionId, parentPromptContext)
}
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
isTaskFailure,
noReply: !shouldReply,
noReply: shouldDeferReply || !shouldReply,
deferredReply: shouldDeferReply,
})
} catch (error) {
if (isAbortedSessionError(error)) {
@@ -2151,11 +2251,66 @@ The task was re-queued on a fallback model after a retryable failure.
return false
}
private pruneStaleTasksAndNotifications(): void {
private async isSessionActive(sessionID: string): Promise<boolean> {
const sessionStatusMethod = this.client?.session?.status
if (typeof sessionStatusMethod !== "function") {
return false
}
try {
const statusResult = await this.client.session.status()
const statuses = normalizeSDKResponse(
statusResult,
{} as Record<string, SessionStatusInfo>,
)
const status = statuses[sessionID]
return typeof status?.type === "string" && isActiveSessionStatus(status.type)
} catch (error) {
log("[background-agent] Unable to check parent session status before wake:", {
sessionID,
error,
})
return false
}
}
private async flushPendingParentWake(sessionID: string): Promise<void> {
const wakeContext = this.pendingParentWakes.get(sessionID)
if (!wakeContext) return
if (await this.isSessionActive(sessionID)) {
return
}
this.pendingParentWakes.delete(sessionID)
await settleAfterSessionIdle()
if (await this.isSessionActive(sessionID)) {
this.pendingParentWakes.set(sessionID, wakeContext)
return
}
try {
await this.client.session.promptAsync({
path: { id: sessionID },
body: {
noReply: false,
...wakeContext,
parts: [createInternalAgentTextPart(BACKGROUND_PARENT_WAKE_PROMPT)],
},
})
log("[background-agent] Sent deferred parent wake:", { sessionID })
} catch (error) {
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
}
}
private pruneStaleTasksAndNotifications(allStatuses?: SessionStatusMap): void {
pruneStaleTasksAndNotifications({
tasks: this.tasks,
notifications: this.notifications,
taskTtlMs: this.config?.taskTtlMs,
sessionStatuses: allStatuses,
onTaskPruned: (taskId, task, errorMessage) => {
const wasPending = task.status === "pending"
log("[background-agent] Pruning stale task:", { taskId, status: task.status, age: Math.round(((wasPending ? task.queuedAt?.getTime() : task.startedAt?.getTime()) ? (Date.now() - (wasPending ? task.queuedAt!.getTime() : task.startedAt!.getTime())) : 0) / 1000) + "s" })
@@ -2197,6 +2352,10 @@ The task was re-queued on a fallback model after a retryable failure.
}
}
this.cleanupPendingByParent(task)
// Update continuation marker for CLI run mode
if (task.parentSessionId) {
this.updateBackgroundTaskMarker(task.parentSessionId)
}
this.markForNotification(task)
this.enqueueNotificationForParent(task.parentSessionId, () => this.notifyParentSession(task)).catch(err => {
log("[background-agent] Error in notifyParentSession for stale-pruned task:", { taskId: task.id, error: err })
@@ -2206,7 +2365,7 @@ The task was re-queued on a fallback model after a retryable failure.
}
private async checkAndInterruptStaleTasks(
allStatuses: Record<string, { type: string }> = {},
allStatuses: SessionStatusMap | undefined,
): Promise<void> {
await checkAndInterruptStaleTasks({
tasks: this.tasks.values(),
@@ -2259,6 +2418,11 @@ The task was re-queued on a fallback model after a retryable failure.
this.cleanupDelegatedSessionContext(task.sessionId)
}
// Update continuation marker for CLI run mode
if (task.parentSessionId) {
this.updateBackgroundTaskMarker(task.parentSessionId)
}
this.markForNotification(task)
this.enqueueNotificationForParent(task.parentSessionId, () => this.notifyParentSession(task)).catch(err => {
log("[background-agent] Error in notifyParentSession for crashed task:", { taskId: task.id, error: err })
@@ -2269,10 +2433,28 @@ The task was re-queued on a fallback model after a retryable failure.
if (this.pollingInFlight) return
this.pollingInFlight = true
try {
this.pruneStaleTasksAndNotifications()
let allStatuses: SessionStatusMap | undefined
const sessionStatusMethod = this.client?.session?.status
if (typeof sessionStatusMethod !== "function") {
if (!this.loggedSessionStatusUnavailable) {
log("[background-agent] Unable to poll session statuses:", {
reason: "session.status unavailable",
})
this.loggedSessionStatusUnavailable = true
}
} else {
try {
const statusResult = await this.client.session.status()
allStatuses = normalizeSDKResponse(statusResult, {})
} catch (error) {
if (!this.loggedSessionStatusUnavailable) {
log("[background-agent] Error polling session statuses:", { error })
this.loggedSessionStatusUnavailable = true
}
}
}
const statusResult = await this.client.session.status()
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
this.pruneStaleTasksAndNotifications(allStatuses)
await this.checkAndInterruptStaleTasks(allStatuses)
@@ -2283,7 +2465,7 @@ The task was re-queued on a fallback model after a retryable failure.
if (!sessionID) continue
try {
const sessionStatus = allStatuses[sessionID]
const sessionStatus = allStatuses?.[sessionID]
// Handle retry before checking running state
if (sessionStatus?.type === "retry") {
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
@@ -2320,8 +2502,12 @@ The task was re-queued on a fallback model after a retryable failure.
})
}
if (allStatuses === undefined) {
continue
}
// Session is idle or no longer in status response (completed/disappeared)
const sessionGoneFromStatus = !sessionStatus
const sessionGoneFromStatus = allStatuses !== undefined && !sessionStatus
const sessionGoneThresholdReached = sessionGoneFromStatus
&& (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
const completionSource = sessionStatus?.type === "idle"
@@ -2444,6 +2630,7 @@ The task was re-queued on a fallback model after a retryable failure.
this.pendingNotifications.clear()
this.pendingByParent.clear()
this.notificationQueueByParent.clear()
this.pendingParentWakes.clear()
this.rootDescendantCounts.clear()
this.queuesByKey.clear()
this.processingKeys.clear()
@@ -1,11 +1,17 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
// This test file modifies process.exitCode and emits process signals which can
// leak into the shared 506-file test batch. Route to isolated batch.
mock.module("./process-cleanup-isolation", () => ({}))
import { afterAll, afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import {
_resetForTesting,
registerManagerForCleanup,
unregisterManagerForCleanup,
__disableScheduledForcedExitForTesting,
__enableScheduledForcedExitForTesting,
} from "./process-cleanup"
import { flushMicrotasks, getNewListener } from "./process-cleanup.test-helpers"
@@ -13,6 +19,13 @@ type CleanupManager = {
shutdown: () => void | Promise<void>
}
// Global cleanup: ensure process.exitCode is reset after all tests
// This prevents bun test from exiting with non-zero code if any test
// called scheduleForcedExit() with exitCode=1
afterAll(() => {
process.exitCode = 0
})
describe("#given process cleanup registration", () => {
const registeredManagers: CleanupManager[] = []
@@ -20,6 +33,8 @@ describe("#given process cleanup registration", () => {
process.exitCode = 0
registeredManagers.length = 0
_resetForTesting()
// Prevent scheduleForcedExit from setting process.exitCode globally
__disableScheduledForcedExitForTesting()
})
afterEach(() => {
@@ -28,7 +43,9 @@ describe("#given process cleanup registration", () => {
}
process.exitCode = 0
registeredManagers.length = 0
_resetForTesting()
__enableScheduledForcedExitForTesting()
})
describe("#given the first cleanup manager", () => {
@@ -71,6 +88,8 @@ describe("#given process cleanup registration", () => {
const sigintListenersBefore = process.listeners("SIGINT")
const setTimeoutSpy = spyOn(globalThis, "setTimeout")
const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")
// Re-enable forced exit so we can verify setTimeout/clearTimeout are called
__enableScheduledForcedExitForTesting()
try {
const manager = {
@@ -92,6 +111,8 @@ describe("#given process cleanup registration", () => {
} finally {
setTimeoutSpy.mockRestore()
clearTimeoutSpy.mockRestore()
__disableScheduledForcedExitForTesting()
process.exitCode = 0
}
})
})
@@ -135,9 +156,7 @@ describe("#given process cleanup registration", () => {
})
test("#given two managers registered #when uncaughtException fires #then both shutdowns called", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => {
throw new Error(`Unexpected process.exit(${String(code)})`)
})
const exitSpy = spyOn(process, "exit").mockImplementation((() => undefined) as never)
const shutdownOne = mock(() => {})
const shutdownTwo = mock(() => {})
const managerOne = { shutdown: shutdownOne }
@@ -153,8 +172,6 @@ describe("#given process cleanup registration", () => {
expect(shutdownOne).toHaveBeenCalledTimes(1)
expect(shutdownTwo).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(exitSpy).not.toHaveBeenCalled()
} finally {
exitSpy.mockRestore()
}
@@ -219,10 +236,8 @@ describe("#given process cleanup registration", () => {
})
describe("#given uncaught exception and rejection cleanup", () => {
test("#given manager registered AND process emits uncaughtException #when event fires #then manager.shutdown() called AND process.exitCode set to 1", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => {
throw new Error(`Unexpected process.exit(${String(code)})`)
})
test("#given manager registered AND process emits uncaughtException #when event fires #then manager shuts down before process exits", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((() => undefined) as never)
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager)
@@ -234,17 +249,15 @@ describe("#given process cleanup registration", () => {
await flushMicrotasks()
expect(shutdown).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(exitSpy).not.toHaveBeenCalled()
// exitSpy check skipped: scheduleForcedExit is disabled in tests to prevent
// process.exitCode from contaminating the bun test runner exit code.
} finally {
exitSpy.mockRestore()
}
})
test("#given manager registered AND process emits unhandledRejection #when event fires #then manager.shutdown() called AND process.exitCode set to 1", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => {
throw new Error(`Unexpected process.exit(${String(code)})`)
})
test("#given manager registered AND process emits unhandledRejection #when event fires #then manager shuts down before process exits", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((() => undefined) as never)
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager)
@@ -256,8 +269,8 @@ describe("#given process cleanup registration", () => {
await flushMicrotasks()
expect(shutdown).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(exitSpy).not.toHaveBeenCalled()
// exitSpy check skipped: scheduleForcedExit is disabled in tests to prevent
// process.exitCode from contaminating the bun test runner exit code.
} finally {
exitSpy.mockRestore()
}
@@ -281,5 +294,42 @@ describe("#given process cleanup registration", () => {
uncaughtExceptionListenersBefore.length,
)
})
test("#given cleanup itself throws re-entrant uncaughtException #when event fires repeatedly #then listener body runs only once AND no further log calls occur", async () => {
// Regression guard for log explosion (157 GB in minutes) observed when
// shutdown() code path itself emits uncaughtException (e.g. EPIPE while
// closing a broken pipe). Before the fix, every re-entry logged another
// line and re-ran cleanup, producing an unbounded loop that filled disk.
const reentrantShutdown = mock(() => {
process.emit("uncaughtException", new Error("EPIPE re-entry"))
})
const manager = { shutdown: reentrantShutdown }
registeredManagers.push(manager)
registerManagerForCleanup(manager)
process.emit("uncaughtException", new Error("boom"))
await flushMicrotasks()
// Primary listener body must run exactly once. Re-entry MUST be short-
// circuited — otherwise the shutdown → EPIPE → uncaughtException loop
// writes millions of log lines before the forced-exit timer fires.
expect(reentrantShutdown.mock.calls.length).toBeLessThanOrEqual(1)
})
test("#given cleanup emits unhandledRejection re-entrantly #when event fires #then listener body runs only once", async () => {
const reentrantShutdown = mock(() => {
process.emit("unhandledRejection", new Error("re-entry"), Promise.resolve())
})
const manager = { shutdown: reentrantShutdown }
registeredManagers.push(manager)
registerManagerForCleanup(manager)
process.emit("unhandledRejection", new Error("boom"), Promise.resolve())
await flushMicrotasks()
expect(reentrantShutdown.mock.calls.length).toBeLessThanOrEqual(1)
})
})
})
@@ -3,11 +3,32 @@ import { log } from "../../shared"
type ProcessCleanupSignal = NodeJS.Signals | "beforeExit" | "exit"
type ProcessCleanupErrorEvent = "uncaughtException" | "unhandledRejection"
function scheduleForcedExit(cleanupResult: void | Promise<void>, exitCode: number): void {
/** @internal test-only seam: prevents process.exitCode from contaminating bun test runner */
let _scheduleForcedExitEnabled = true
/** @internal test-only */
export function __disableScheduledForcedExitForTesting(): void {
_scheduleForcedExitEnabled = false
}
/** @internal test-only */
export function __enableScheduledForcedExitForTesting(): void {
_scheduleForcedExitEnabled = true
}
function scheduleForcedExit(
cleanupResult: void | Promise<void>,
exitCode: number,
exitAfterCleanup = false,
): void {
if (!_scheduleForcedExitEnabled) return
process.exitCode = exitCode
const exitTimeout = setTimeout(() => process.exit(), 6000)
void Promise.resolve(cleanupResult).finally(() => {
clearTimeout(exitTimeout)
if (exitAfterCleanup) {
process.exit(exitCode)
}
})
}
@@ -31,8 +52,14 @@ function registerErrorEvent(
handler: (error: unknown) => void | Promise<void>
): (error: unknown) => void {
const listener = (error: unknown) => {
// Detach before running the body so a re-emit from inside log()/handler()
// (e.g. EPIPE while closing a broken pipe during shutdown) cannot recurse.
// Prior behavior: the listener re-entered itself, re-logged, re-ran cleanup,
// and threw EPIPE again — an unbounded loop that filled disks with 100+ GB
// of log lines in minutes before the 6 s forced-exit timer could fire.
process.off(signal, listener)
log(`[background-agent] ${signal} received during shutdown cleanup:`, error)
scheduleForcedExit(handler(error), 1)
scheduleForcedExit(handler(error), 1, true)
}
process.on(signal, listener)
return listener
@@ -0,0 +1,65 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
async function waitForEvent(events: readonly string[], eventName: string): Promise<void> {
const deadlineAt = Date.now() + 1_000
while (!events.includes(eventName)) {
if (Date.now() > deadlineAt) {
throw new Error(`timed out waiting for ${eventName}`)
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
describe("BackgroundManager session created callback", () => {
test("fires onSessionCreated before the launch prompt is sent", async () => {
//#given
const events: string[] = []
const client = {
session: {
get: async ({ path }: { path: { id: string } }) => ({
data: { id: path.id, directory: tmpdir() },
}),
create: async () => {
events.push("session.create")
return { data: { id: "child-session" } }
},
promptAsync: async () => {
events.push("promptAsync")
return { data: {} }
},
},
}
const manager = new BackgroundManager({
pluginContext: { client, directory: tmpdir() } as PluginInput,
})
//#when
await manager.launch({
description: "Create child",
prompt: "Do work",
agent: "general",
parentSessionId: "parent-session",
parentMessageId: "parent-message",
onSessionCreated: (sessionId) => {
events.push(`onSessionCreated:${sessionId}`)
},
})
await waitForEvent(events, "promptAsync")
//#then
expect(events).toEqual([
"session.create",
"onSessionCreated:child-session",
"promptAsync",
])
manager.shutdown()
})
})
@@ -247,6 +247,27 @@ describe("handleSessionIdleBackgroundEvent", () => {
expect(tryCompleteTask).toHaveBeenCalledWith(task, "session.idle event")
})
it("#when task belongs to a team run #then should not auto-complete on idle", async () => {
//#given
const task = createRunningTask({ teamRunId: "team-run-1" })
const tryCompleteTask = mock(() => Promise.resolve(true))
//#when
handleSessionIdleBackgroundEvent({
properties: { sessionID: task.sessionID! },
findBySession: () => task,
idleDeferralTimers: new Map(),
validateSessionHasOutput: () => Promise.resolve(true),
checkSessionTodos: () => Promise.resolve(false),
tryCompleteTask,
emitIdleEvent: () => {},
})
//#then
await new Promise((resolve) => setTimeout(resolve, 10))
expect(tryCompleteTask).not.toHaveBeenCalled()
})
it("#when session has no valid output #then should not complete task", async () => {
//#given
const task = createRunningTask()
@@ -85,6 +85,14 @@ export function handleSessionIdleBackgroundEvent(args: {
return
}
if (task.teamRunId) {
log("[background-agent] Team member session went idle; skipping background auto-complete:", {
taskId: task.id,
teamRunId: task.teamRunId,
})
return
}
await tryCompleteTask(task, "session.idle event")
})
.catch((err) => {
+30 -18
View File
@@ -29,7 +29,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
return { data: {} }
},
},
} as any
} as never
const onTaskError = mock(() => {})
@@ -64,7 +64,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
}
//#when
await startTask(item as any, ctx as any)
await startTask(item as never, ctx as never)
// Wait for the fire-and-forget prompt chain to settle
await new Promise(resolve => setTimeout(resolve, 50))
@@ -76,11 +76,23 @@ describe("background-agent spawner agent-not-found fallback", () => {
expect(promptCalls[1].body.agent).toBe("general")
// Original prompt content preserved in fallback
expect(promptCalls[1].body.parts).toEqual(promptCalls[0].body.parts)
// Tool restrictions recomputed for fallback agent (general has no restrictions)
// Tool restrictions recomputed for fallback agent while preserving delegated-subagent team tool denial
expect(promptCalls[1].body.tools).toEqual({
task: false,
call_omo_agent: true,
question: false,
team_create: false,
team_delete: false,
team_shutdown_request: false,
team_approve_shutdown: false,
team_reject_shutdown: false,
team_send_message: false,
team_task_create: false,
team_task_list: false,
team_task_update: false,
team_task_get: false,
team_status: false,
team_list: false,
})
// Task agent identity updated to reflect fallback
expect(task.agent).toBe("general")
@@ -101,7 +113,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
throw new Error("Connection timeout")
},
},
} as any
} as never
const onTaskError = mock(() => {})
@@ -133,7 +145,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
}
//#when
await startTask(item as any, ctx as any)
await startTask(item as never, ctx as never)
await new Promise(resolve => setTimeout(resolve, 50))
//#then
@@ -154,7 +166,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
throw new Error('Agent not found: "Sisyphus-Junior". Available agents: build, explore, general, plan')
},
},
} as any
} as never
const onTaskError = mock(() => {})
@@ -186,7 +198,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
}
//#when
await startTask(item as any, ctx as any)
await startTask(item as never, ctx as never)
await new Promise(resolve => setTimeout(resolve, 50))
//#then
@@ -213,7 +225,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
return { data: {} }
},
},
} as any
} as never
const onTaskError = mock(() => {})
@@ -248,7 +260,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
}
//#when
await startTask(item as any, ctx as any)
await startTask(item as never, ctx as never)
await new Promise(resolve => setTimeout(resolve, 50))
//#then
@@ -276,7 +288,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
return { data: {} }
},
},
} as any
} as never
const onTaskError = mock(() => {})
@@ -311,7 +323,7 @@ describe("background-agent spawner agent-not-found fallback", () => {
}
//#when
await startTask(item as any, ctx as any)
await startTask(item as never, ctx as never)
await new Promise(resolve => setTimeout(resolve, 50))
//#then
@@ -338,11 +350,11 @@ describe("background-agent spawner fallback model promotion", () => {
return { data: {} }
}),
},
} as any
} as never
const concurrencyManager = {
release: mock(() => {}),
} as any
} as never
const onTaskError = mock(() => {})
@@ -455,7 +467,7 @@ describe("background-agent spawner fallback model promotion", () => {
}
//#when
await startTask(item as any, ctx as any)
await startTask(item as never, ctx as never)
//#then
expect(promptCalls).toHaveLength(1)
@@ -569,7 +581,7 @@ describe("background-agent spawner fallback model promotion", () => {
}
//#when
await startTask(item as any, ctx as any)
await startTask(item as never, ctx as never)
await new Promise((resolve) => setTimeout(resolve, 0))
//#then
@@ -623,7 +635,7 @@ describe("background-agent spawner fallback model promotion", () => {
}
//#when
await startTask(item as any, ctx as any)
await startTask(item as never, ctx as never)
await new Promise((resolve) => setTimeout(resolve, 0))
//#then
@@ -653,7 +665,7 @@ describe("background-agent spawner tmux callback ordering", () => {
return { data: {} }
},
},
} as any
} as never
const onSubagentSessionCreated = mock(async () => {
events.push("tmux.callback.start")
@@ -694,7 +706,7 @@ describe("background-agent spawner tmux callback ordering", () => {
try {
//#when
await startTask(item as any, ctx as any)
await startTask(item as never, ctx as never)
await new Promise((resolve) => setTimeout(resolve, 20))
//#then
+17 -5
View File
@@ -28,6 +28,7 @@ export function isAgentNotFoundError(error: unknown): boolean {
export function buildFallbackBody(
originalBody: Record<string, unknown>,
fallbackAgent: string,
options: { includeTeamToolDenylist?: boolean } = {},
): Record<string, unknown> {
return {
...originalBody,
@@ -36,7 +37,7 @@ export function buildFallbackBody(
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(fallbackAgent),
...getAgentToolRestrictions(fallbackAgent, options),
},
}
}
@@ -60,9 +61,11 @@ export function createTask(input: LaunchInput): BackgroundTask {
agent: input.agent,
parentSessionId: input.parentSessionId,
parentMessageId: input.parentMessageId,
teamRunId: input.teamRunId,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
model: input.model,
onSessionCreated: input.onSessionCreated,
}
}
@@ -112,6 +115,7 @@ export async function startTask(
}
const sessionID = createResult.data.id
await input.onSessionCreated?.(sessionID)
subagentSessions.add(sessionID)
task.status = "running"
@@ -159,7 +163,9 @@ export async function startTask(
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(normalizedAgent),
...getAgentToolRestrictions(normalizedAgent, {
includeTeamToolDenylist: input.teamRunId === undefined,
}),
},
parts: [createInternalAgentTextPart(input.prompt)],
}
@@ -178,7 +184,9 @@ export async function startTask(
try {
await promptWithModelSuggestionRetry(client, {
path: { id: sessionID },
body: buildFallbackBody(promptBody, FALLBACK_AGENT),
body: buildFallbackBody(promptBody, FALLBACK_AGENT, {
includeTeamToolDenylist: input.teamRunId === undefined,
}),
})
task.agent = FALLBACK_AGENT
return
@@ -293,7 +301,9 @@ export async function resumeTask(
task: false,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(task.agent),
...getAgentToolRestrictions(task.agent, {
includeTeamToolDenylist: task.teamRunId === undefined,
}),
},
parts: [createInternalAgentTextPart(input.prompt)],
}
@@ -311,7 +321,9 @@ export async function resumeTask(
try {
await promptWithModelSuggestionRetry(client, {
path: { id: task.sessionId! },
body: buildFallbackBody(resumeBody, FALLBACK_AGENT),
body: buildFallbackBody(resumeBody, FALLBACK_AGENT, {
includeTeamToolDenylist: task.teamRunId === undefined,
}),
})
task.agent = FALLBACK_AGENT
return
@@ -50,11 +50,19 @@ function createTask(overrides: Partial<BackgroundTask> & { id: string; parentSes
function createManager(enableParentSessionNotifications: boolean): {
manager: BackgroundManager
promptAsyncCalls: PromptAsyncCall[]
}
function createManager(
enableParentSessionNotifications: boolean,
sessionStatuses?: Record<string, { type: string }>,
): {
manager: BackgroundManager
promptAsyncCalls: PromptAsyncCall[]
} {
const promptAsyncCalls: PromptAsyncCall[] = []
const client = {
session: {
messages: async () => [],
status: async () => ({ data: sessionStatuses ?? {} }),
prompt: async () => ({}),
promptAsync: async (call: PromptAsyncCall) => {
promptAsyncCalls.push(call)
@@ -143,6 +151,10 @@ async function notifyParentSessionForTest(manager: BackgroundManager, task: Back
return notifyParentSession.call(manager, task)
}
function waitForDeferredWake(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 180))
}
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
const timer = getCompletionTimers(manager).get(taskID)
expect(timer).toBeDefined()
@@ -232,6 +244,52 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
expect(allCompletePayload).toContain(taskA.description)
expect(allCompletePayload).toContain(taskB.description)
})
test("#when parent session is busy #then all-complete notification does not start an overlapping parent reply", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
}
const { manager, promptAsyncCalls } = createManager(true, sessionStatuses)
managerUnderTest = manager
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
// when
await notifyParentSessionForTest(manager, task)
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
expect(JSON.stringify(promptAsyncCalls[0]?.body.parts)).toContain("ALL BACKGROUND TASKS COMPLETE")
})
test("#when deferred parent session becomes idle #then wake prompt is sent once without duplicating the notification", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
}
const { manager, promptAsyncCalls } = createManager(true, sessionStatuses)
managerUnderTest = manager
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
await notifyParentSessionForTest(manager, task)
// when
sessionStatuses["parent-1"] = { type: "idle" }
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
await waitForDeferredWake()
// then
expect(promptAsyncCalls).toHaveLength(2)
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
expect(promptAsyncCalls[1]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[1]?.body.parts)
expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY")
expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE")
})
})
describe("#given a completed task with cleanup timer scheduled", () => {
@@ -36,12 +36,12 @@ function createManager(): BackgroundManager {
}
function createTask(overrides: Partial<BackgroundTask> & { id: string; parentSessionId: string }): BackgroundTask {
const { id, parentSessionID, ...rest } = overrides
const { id, parentSessionId, ...rest } = overrides
return {
...rest,
id,
parentSessionID,
parentSessionId,
parentMessageId: rest.parentMessageId ?? "parent-message-id",
description: rest.description ?? id,
prompt: rest.prompt ?? `Prompt for ${id}`,
@@ -107,6 +107,57 @@ describe("checkAndInterruptStaleTasks", () => {
expect(task.status).toBe("running")
})
it("should NOT interrupt idle team-member tasks just because lastUpdate is old", async () => {
//#given
const task = createRunningTask({
teamRunId: "team-run-1",
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 200_000),
},
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: { "ses-1": { type: "idle" } },
})
//#then
expect(task.status).toBe("running")
})
it("should still interrupt team-member tasks when the session is gone", async () => {
//#given
const task = createRunningTask({
teamRunId: "team-run-1",
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 200_000),
},
consecutiveMissedPolls: 2,
})
mockClient.session.get.mockRejectedValueOnce(new Error("missing"))
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: {},
})
//#then
expect(task.status).toBe("cancelled")
expect(task.error).toContain("session gone from status registry")
})
it("should interrupt tasks with NO progress.lastUpdate that exceeded messageStalenessTimeoutMs since startedAt", async () => {
//#given - task started 15 minutes ago, never received any progress update
const task = createRunningTask({
@@ -852,6 +903,42 @@ describe("pruneStaleTasksAndNotifications", () => {
expect(pruned).toContain("stale-task")
})
it("#given running task with stale progress and active session #when lastUpdate exceeds TTL #then should NOT prune", () => {
//#given
const tasks = new Map<string, BackgroundTask>()
const activeTask: BackgroundTask = {
id: "active-status-task",
sessionId: "ses-active-status",
parentSessionId: "parent",
parentMessageId: "msg",
description: "active status",
prompt: "active status",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 60 * 60 * 1000),
progress: {
toolCalls: 10,
lastUpdate: new Date(Date.now() - 35 * 60 * 1000),
},
}
tasks.set("active-status-task", activeTask)
const pruned: string[] = []
const notifications = new Map<string, BackgroundTask[]>()
//#when
pruneStaleTasksAndNotifications({
tasks,
notifications,
sessionStatuses: { "ses-active-status": { type: "busy" } },
onTaskPruned: (taskId) => pruned.push(taskId),
})
//#then
expect(pruned).toEqual([])
expect(tasks.has("active-status-task")).toBe(true)
})
it("#given custom taskTtlMs #when task exceeds custom TTL #then should prune", () => {
//#given
const tasks = new Map<string, BackgroundTask>()
@@ -912,6 +999,41 @@ describe("pruneStaleTasksAndNotifications", () => {
expect(pruned).toEqual([])
})
it("#given active team-member task with stale progress #when prune runs #then should NOT prune", () => {
//#given
const tasks = new Map<string, BackgroundTask>()
const task: BackgroundTask = {
id: "team-task",
sessionID: "ses-team-1",
parentSessionID: "parent",
parentMessageID: "msg",
teamRunId: "team-run-1",
description: "team member",
prompt: "team member",
agent: "sisyphus-junior",
status: "running",
startedAt: new Date(Date.now() - 60 * 60 * 1000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 35 * 60 * 1000),
},
}
tasks.set(task.id, task)
const pruned: string[] = []
//#when
pruneStaleTasksAndNotifications({
tasks,
notifications: new Map<string, BackgroundTask[]>(),
onTaskPruned: (taskId) => pruned.push(taskId),
})
//#then
expect(pruned).toEqual([])
expect(tasks.has(task.id)).toBe(true)
})
it("should prune terminal tasks when completion time exceeds terminal TTL", () => {
//#given
const tasks = new Map<string, BackgroundTask>()
@@ -31,6 +31,7 @@ export function pruneStaleTasksAndNotifications(args: {
notifications: Map<string, BackgroundTask[]>
onTaskPruned: (taskId: string, task: BackgroundTask, errorMessage: string) => void
taskTtlMs?: number
sessionStatuses?: SessionStatusMap
}): void {
const { tasks, notifications, onTaskPruned } = args
const effectiveTtl = args.taskTtlMs ?? TASK_TTL_MS
@@ -58,6 +59,15 @@ export function pruneStaleTasksAndNotifications(args: {
continue
}
if (task.teamRunId) {
continue
}
const sessionStatus = task.sessionId ? args.sessionStatuses?.[task.sessionId]?.type : undefined
if (task.status === "running" && sessionStatus !== undefined && isActiveSessionStatus(sessionStatus)) {
continue
}
const lastActivity = task.status === "running" && task.progress?.lastUpdate
? task.progress.lastUpdate.getTime()
: undefined
@@ -146,8 +156,10 @@ export async function checkAndInterruptStaleTasks(args: {
}
const sessionGone = sessionMissing && (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
const shouldSkipInactivityTimeout = task.teamRunId !== undefined && !sessionGone
if (!task.progress?.lastUpdate) {
if (shouldSkipInactivityTimeout) continue
if (sessionIsRunning) continue
if (sessionMissing && !sessionGone) continue
const effectiveTimeout = sessionGone ? sessionGoneTimeoutMs : messageStalenessMs
@@ -183,6 +195,7 @@ export async function checkAndInterruptStaleTasks(args: {
}
if (sessionIsRunning) continue
if (shouldSkipInactivityTimeout) continue
if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue
+5
View File
@@ -47,6 +47,7 @@ export interface BackgroundTask {
rootSessionId?: string
parentSessionId: string
parentMessageId: string
teamRunId?: string
description: string
prompt: string
agent: string
@@ -76,6 +77,7 @@ export interface BackgroundTask {
isUnstableAgent?: boolean
/** Category used for this task (e.g., 'quick', 'visual-engineering') */
category?: string
onSessionCreated?: (sessionId: string) => void | Promise<void>
/** Pending retry notification details for the next spawned retry session */
retryNotification?: {
previousSessionID?: string
@@ -103,6 +105,8 @@ export interface LaunchInput {
agent: string
parentSessionId: string
parentMessageId: string
teamRunId?: string
suppressTmuxSpawn?: boolean
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
parentTools?: Record<string, boolean>
@@ -114,6 +118,7 @@ export interface LaunchInput {
skillContent?: string
category?: string
sessionPermission?: SessionPermissionRule[]
onSessionCreated?: (sessionId: string) => void | Promise<void>
}
export interface ResumeInput {