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:
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user