From 59865836413d3c8d19bd0c23503d00006d977a53 Mon Sep 17 00:00:00 2001 From: Casey Howard Date: Fri, 20 Mar 2026 12:51:21 -0400 Subject: [PATCH 1/2] fix(background-agent): decrement spawn budget on task completion, cancellation, error, and interrupt rootDescendantCounts was incremented on every spawn but never decremented when tasks reached terminal states (completed, cancelled, error, interrupt, stale-pruned). This made maxDescendants=50 a session-lifetime quota instead of its intended semantics as a concurrent-active agent cap. Fix: add unregisterRootDescendant() in five terminal-state handlers: - tryCompleteTask(): task completes successfully - cancelTask(): running task cancelled (wasRunning guard prevents double-decrement for pending tasks already handled by rollbackPreStartDescendantReservation) - session.error handler: task errors - promptAsync catch (startTask): task interrupted on launch - promptAsync catch (resume): task interrupted on resume - onTaskPruned callback: stale task pruned (wasPending guard) Fixes: code-yeongyu/oh-my-openagent#2700 --- src/features/background-agent/manager.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index c4ea7528b..4de7298d6 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -543,6 +543,9 @@ export class BackgroundManager { existingTask.error = errorMessage } existingTask.completedAt = new Date() + if (existingTask.rootSessionID) { + this.unregisterRootDescendant(existingTask.rootSessionID) + } if (existingTask.concurrencyKey) { this.concurrencyManager.release(existingTask.concurrencyKey) existingTask.concurrencyKey = undefined @@ -813,6 +816,9 @@ export class BackgroundManager { const errorMessage = error instanceof Error ? error.message : String(error) existingTask.error = errorMessage existingTask.completedAt = new Date() + if (existingTask.rootSessionID) { + this.unregisterRootDescendant(existingTask.rootSessionID) + } // Release concurrency on error to prevent slot leaks if (existingTask.concurrencyKey) { @@ -1009,6 +1015,9 @@ export class BackgroundManager { task.status = "error" task.error = errorMsg task.completedAt = new Date() + if (task.rootSessionID) { + this.unregisterRootDescendant(task.rootSessionID) + } this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt }) if (task.concurrencyKey) { @@ -1341,8 +1350,12 @@ export class BackgroundManager { log("[background-agent] Cancelled pending task:", { taskId, key }) } + const wasRunning = task.status === "running" task.status = "cancelled" task.completedAt = new Date() + if (wasRunning && task.rootSessionID) { + this.unregisterRootDescendant(task.rootSessionID) + } if (reason) { task.error = reason } @@ -1463,6 +1476,10 @@ export class BackgroundManager { task.completedAt = new Date() this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt }) + if (task.rootSessionID) { + this.unregisterRootDescendant(task.rootSessionID) + } + removeTaskToastTracking(task.id) // Release concurrency BEFORE any async operations to prevent slot leaks @@ -1701,6 +1718,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea task.status = "error" task.error = errorMessage task.completedAt = new Date() + if (!wasPending && task.rootSessionID) { + this.unregisterRootDescendant(task.rootSessionID) + } this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt }) if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) From 031503bb8c2d069c3ead1d07b32e9330c5955cef Mon Sep 17 00:00:00 2001 From: Casey Howard Date: Fri, 20 Mar 2026 12:52:06 -0400 Subject: [PATCH 2/2] test(background-agent): add regression tests for spawn budget decrement on task completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests prove rootDescendantCounts is never decremented on task completion, cancellation, or error — making maxDescendants a lifetime quota instead of a concurrent-active cap. All 4 tests fail (RED phase) before the fix. Refs: code-yeongyu/oh-my-openagent#2700 --- src/features/background-agent/manager.test.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index bef56a647..908750c74 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -2359,6 +2359,133 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { expect(abortCalls).toEqual([createdSessionID]) expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0) }) + + test("should release descendant quota when task completes", async () => { + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDescendants: 1 }, + ) + stubNotifyParentSession(manager) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-root", + parentMessageID: "parent-message", + } + + const task = await manager.launch(input) + const internalTask = getTaskMap(manager).get(task.id)! + internalTask.status = "running" + internalTask.sessionID = "child-session-complete" + internalTask.rootSessionID = "session-root" + + // Complete via internal method (session.status events go through the poller, not handleEvent) + await tryCompleteTaskForTest(manager, internalTask) + + await expect(manager.launch(input)).resolves.toBeDefined() + }) + + test("should release descendant quota when running task is cancelled", async () => { + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDescendants: 1 }, + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-root", + parentMessageID: "parent-message", + } + + const task = await manager.launch(input) + const internalTask = getTaskMap(manager).get(task.id)! + internalTask.status = "running" + internalTask.sessionID = "child-session-cancel" + + await manager.cancelTask(task.id) + + await expect(manager.launch(input)).resolves.toBeDefined() + }) + + test("should release descendant quota when task errors", async () => { + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDescendants: 1 }, + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-root", + parentMessageID: "parent-message", + } + + const task = await manager.launch(input) + const internalTask = getTaskMap(manager).get(task.id)! + internalTask.status = "running" + internalTask.sessionID = "child-session-error" + + manager.handleEvent({ + type: "session.error", + properties: { sessionID: internalTask.sessionID, info: { id: internalTask.sessionID } }, + }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + await expect(manager.launch(input)).resolves.toBeDefined() + }) + + test("should not double-decrement quota when pending task is cancelled", async () => { + manager.shutdown() + manager = new BackgroundManager( + { + client: createMockClientWithSessionChain({ + "session-root": { directory: "/test/dir" }, + }), + directory: tmpdir(), + } as unknown as PluginInput, + { maxDescendants: 2 }, + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "session-root", + parentMessageID: "parent-message", + } + + const task1 = await manager.launch(input) + const task2 = await manager.launch(input) + + await manager.cancelTask(task1.id) + await manager.cancelTask(task2.id) + + await expect(manager.launch(input)).resolves.toBeDefined() + await expect(manager.launch(input)).resolves.toBeDefined() + }) }) describe("pending task can be cancelled", () => {