From dbaea82b739f3f268d155cc6721cedb5c93bfac3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 14:56:28 +0900 Subject: [PATCH 1/4] fix(background-agent): retain completed tasks via archive fallback after cleanup MessageAbortedError/worker shutdown could race with scheduled removeTask, leaving background_output's manager.getTask returning 'Task not found' even though the task had completed cleanly. Fix: add completedTaskArchive (max 500, FIFO eviction). On removeTask, archive non-running/pending tasks with sessionId. getTask falls back to archive on active-map miss. addTask clears stale archive entries on re-registration. Fixes #3895 --- src/features/background-agent/manager.test.ts | 27 +++++++++++++++++++ src/features/background-agent/manager.ts | 25 ++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 5f622466b..9470d12a7 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -5991,6 +5991,33 @@ 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) + expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId) + + 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..a5c988a7b 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 = 500 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,30 @@ 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 + } + + this.completedTaskArchive.set(task.id, task) + 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 +853,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[] { From 6fd1ec3ea8f107abb0190c1dbd14a02584145dd4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 10 May 2026 15:05:31 +0900 Subject: [PATCH 2/4] test(background-agent): align cancel cleanup assertions with archive fallback --- src/features/background-agent/cancel-task-cleanup.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 () => { From a4ce0b63b6aa8ea30c8c9f87ab03804febf5b7e4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:47:39 +0900 Subject: [PATCH 3/4] fix(background-agent): redact archived prompts and cap fallback archive Store only sanitized completed-task snapshots in archive to avoid retaining sensitive prompts after cleanup. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 36 ++++++++++++++++++- src/features/background-agent/manager.ts | 19 ++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 9470d12a7..fc46a1c5d 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -6014,7 +6014,41 @@ describe("BackgroundManager regression fixes - resume and aborted notification", //#then expect(getTaskMap(manager).has(task.id)).toBe(false) - expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId) + const archivedTask = manager.getTask(task.id) + expect(archivedTask?.sessionId).toBe(task.sessionId) + expect(archivedTask?.prompt).toBe("[redacted]") + + 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() }) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index a5c988a7b..47739930a 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -188,7 +188,7 @@ export interface SubagentSessionCreatedEvent { export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise const MAX_TASK_REMOVAL_RESCHEDULES = 6 -const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 500 +const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 100 export interface BackgroundManagerConfig { pluginContext: PluginInput @@ -374,7 +374,22 @@ export class BackgroundManager { return } - this.completedTaskArchive.set(task.id, task) + 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, + 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 } From eb6605a87f7fd69a8d7975b993901cf4335f1c76 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 11 May 2026 08:53:44 +0900 Subject: [PATCH 4/4] fix(background-agent): preserve non-sensitive timing in archived tasks Carry queuedAt and startedAt in sanitized archive snapshots so post-cleanup task output duration remains accurate without retaining prompt content. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 1 + src/features/background-agent/manager.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index fc46a1c5d..6c92398ad 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -6017,6 +6017,7 @@ describe("BackgroundManager regression fixes - resume and aborted notification", 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() }) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 47739930a..540c65d58 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -383,6 +383,8 @@ export class BackgroundManager { agent: task.agent, sessionId: task.sessionId, status: task.status, + queuedAt: task.queuedAt, + startedAt: task.startedAt, completedAt: task.completedAt, model: task.model, error: task.error,