diff --git a/src/features/background-agent/cancel-task-cleanup.test.ts b/src/features/background-agent/cancel-task-cleanup.test.ts index d8e43f95b..19bb03351 100644 --- a/src/features/background-agent/cancel-task-cleanup.test.ts +++ b/src/features/background-agent/cancel-task-cleanup.test.ts @@ -107,7 +107,8 @@ describe("BackgroundManager.cancelTask cleanup", () => { expect(cancelled).toBe(true) expect(getPendingByParent(manager).get(task.parentSessionId)).toBeUndefined() runScheduledCleanup(manager, task.id) - expect(manager.getTask(task.id)).toBeUndefined() + expect(getTaskMap(manager).has(task.id)).toBe(false) + expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId) }) test("#given a running task #when cancelTask called with skipNotification=false #then task is also eventually removed", async () => { @@ -131,7 +132,8 @@ describe("BackgroundManager.cancelTask cleanup", () => { // then expect(cancelled).toBe(true) runScheduledCleanup(manager, task.id) - expect(manager.getTask(task.id)).toBeUndefined() + expect(getTaskMap(manager).has(task.id)).toBe(false) + expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId) }) test("#given a running task #when cancelTask called with skipNotification=true #then concurrency slot is freed and pending tasks can start", async () => { diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 5f622466b..6c92398ad 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -5991,6 +5991,68 @@ describe("BackgroundManager regression fixes - resume and aborted notification", manager.shutdown() }) + + test("should keep completed task retrievable after scheduled removal", () => { + //#given + const manager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-archive-regression", + sessionId: "session-archive-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "archive regression", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + getTaskMap(manager).set(task.id, task) + + //#when + ;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task) + + //#then + expect(getTaskMap(manager).has(task.id)).toBe(false) + const archivedTask = manager.getTask(task.id) + expect(archivedTask?.sessionId).toBe(task.sessionId) + expect(archivedTask?.prompt).toBe("[redacted]") + expect(archivedTask?.startedAt).toEqual(task.startedAt) + + manager.shutdown() + }) + + test("should cap completed task archive size at 100 entries", () => { + //#given + const manager = createBackgroundManager() + + //#when + for (let index = 0; index < 120; index += 1) { + const task: BackgroundTask = { + id: `task-archive-${index}`, + sessionId: `session-archive-${index}`, + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "archive cap regression", + prompt: `sensitive-${index}`, + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + ;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task) + } + + //#then + const archive = cast>(Reflect.get(manager, "completedTaskArchive")) + expect(archive.size).toBe(100) + expect(archive.has("task-archive-0")).toBe(false) + expect(archive.has("task-archive-19")).toBe(false) + expect(archive.has("task-archive-20")).toBe(true) + expect(archive.has("task-archive-119")).toBe(true) + + manager.shutdown() + }) }) describe("BackgroundManager - tool permission spread order", () => { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 85df81f1a..540c65d58 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -188,6 +188,7 @@ export interface SubagentSessionCreatedEvent { export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise const MAX_TASK_REMOVAL_RESCHEDULES = 6 +const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 100 export interface BackgroundManagerConfig { pluginContext: PluginInput @@ -222,6 +223,7 @@ export class BackgroundManager { private queuesByKey: Map = new Map() private processingKeys: Set = new Set() private completionTimers: Map> = new Map() + private completedTaskArchive: Map = new Map() private completedTaskSummaries: Map = new Map() private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() @@ -347,6 +349,7 @@ export class BackgroundManager { } private addTask(task: BackgroundTask): void { + this.completedTaskArchive.delete(task.id) this.tasks.set(task.id, task) if (!task.parentSessionId) { return @@ -358,10 +361,47 @@ export class BackgroundManager { } private removeTask(task: BackgroundTask): void { + this.archiveCompletedTask(task) this.tasks.delete(task.id) this.removeTaskFromParentIndex(task.id, task.parentSessionId) } + private archiveCompletedTask(task: BackgroundTask): void { + if (!task.sessionId) { + return + } + if (task.status === "running" || task.status === "pending") { + return + } + + const archivedTask: BackgroundTask = { + id: task.id, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + description: task.description, + prompt: "[redacted]", + agent: task.agent, + sessionId: task.sessionId, + status: task.status, + queuedAt: task.queuedAt, + startedAt: task.startedAt, + completedAt: task.completedAt, + model: task.model, + error: task.error, + category: task.category, + } + + this.completedTaskArchive.set(task.id, archivedTask) + if (this.completedTaskArchive.size <= MAX_COMPLETED_TASK_ARCHIVE_SIZE) { + return + } + + const oldestTaskID = this.completedTaskArchive.keys().next().value + if (typeof oldestTaskID === "string") { + this.completedTaskArchive.delete(oldestTaskID) + } + } + private updateTaskParent(task: BackgroundTask, parentSessionID: string): void { if (task.parentSessionId === parentSessionID) { return @@ -830,7 +870,7 @@ The fallback retry session is now created and can be inspected directly. } getTask(id: string): BackgroundTask | undefined { - return this.tasks.get(id) + return this.tasks.get(id) ?? this.completedTaskArchive.get(id) } getTasksByParentSession(sessionID: string): BackgroundTask[] {