Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 4b2b91f

Browse files
committed
fix: add user notifications for background task completion, errors, and timeouts
- Add BackgroundTaskRunnerCallbacks interface with onTaskTimeout and onTaskError - Wire VS Code notifications in ClineProvider: info on completion, warning on timeout/error - Document auto-approval design decision for read-only background tasks - Add 2 new tests for callback invocation (19 total, all passing)
1 parent 903ff8c commit 4b2b91f

4 files changed

Lines changed: 78 additions & 7 deletions

File tree

src/core/task/BackgroundTaskRunner.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,31 @@ export interface BackgroundTaskInfo {
3535
timeoutHandle: ReturnType<typeof setTimeout>
3636
}
3737

38+
/**
39+
* Optional callbacks that allow the owner (e.g. ClineProvider) to react to
40+
* background task lifecycle events such as completion, timeout, or errors.
41+
*/
42+
export interface BackgroundTaskRunnerCallbacks {
43+
/** Called when a background task times out. */
44+
onTaskTimeout?: (taskId: string, parentTaskId: string) => void
45+
/** Called when aborting a background task throws an error. */
46+
onTaskError?: (taskId: string, parentTaskId: string, error: Error) => void
47+
}
48+
3849
export class BackgroundTaskRunner {
3950
private backgroundTasks: Map<string, BackgroundTaskInfo> = new Map()
4051
private maxConcurrentTasks: number
4152
private taskTimeoutMs: number
53+
private callbacks: BackgroundTaskRunnerCallbacks
4254

4355
constructor(
4456
maxConcurrentTasks: number = DEFAULT_MAX_BACKGROUND_TASKS,
4557
taskTimeoutMs: number = DEFAULT_BACKGROUND_TASK_TIMEOUT_MS,
58+
callbacks: BackgroundTaskRunnerCallbacks = {},
4659
) {
4760
this.maxConcurrentTasks = maxConcurrentTasks
4861
this.taskTimeoutMs = taskTimeoutMs
62+
this.callbacks = callbacks
4963
}
5064

5165
/**
@@ -163,11 +177,13 @@ export class BackgroundTaskRunner {
163177
try {
164178
await info.task.abortTask(true)
165179
} catch (error) {
166-
console.error(
167-
`[BackgroundTaskRunner] Error aborting background task ${taskId}: ${
168-
error instanceof Error ? error.message : String(error)
169-
}`,
170-
)
180+
const err = error instanceof Error ? error : new Error(String(error))
181+
console.error(`[BackgroundTaskRunner] Error aborting background task ${taskId}: ${err.message}`)
182+
try {
183+
this.callbacks.onTaskError?.(taskId, info.parentTaskId, err)
184+
} catch {
185+
// Callback errors must not break cleanup.
186+
}
171187
}
172188

173189
this.backgroundTasks.delete(taskId)
@@ -193,7 +209,17 @@ export class BackgroundTaskRunner {
193209
* Handle timeout of a background task.
194210
*/
195211
private async timeoutTask(taskId: string): Promise<void> {
212+
const info = this.backgroundTasks.get(taskId)
213+
const parentTaskId = info?.parentTaskId ?? "unknown"
214+
196215
console.warn(`[BackgroundTaskRunner] Background task ${taskId} timed out after ${this.taskTimeoutMs}ms`)
216+
217+
try {
218+
this.callbacks.onTaskTimeout?.(taskId, parentTaskId)
219+
} catch {
220+
// Callback errors must not break cleanup.
221+
}
222+
197223
await this.cancelTask(taskId)
198224
}
199225
}

src/core/task/Task.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13381338
let timeouts: NodeJS.Timeout[] = []
13391339

13401340
// Background tasks auto-approve all asks immediately (no user interaction).
1341+
// Design decision: Full auto-approval is safe here because background tasks
1342+
// are restricted to read-only tools only (read_file, list_files, search_files,
1343+
// codebase_search). They cannot modify files, execute commands, or perform any
1344+
// destructive operations. If a future phase introduces write-capable background
1345+
// tasks, this auto-approval should be revisited to allow selective user input
1346+
// for dangerous operations.
13411347
if (this.isBackgroundTask) {
13421348
this.approveAsk()
13431349
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })

src/core/task/__tests__/BackgroundTaskRunner.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ describe("BackgroundTaskRunner", () => {
129129
it("should handle canceling unknown task gracefully", async () => {
130130
await runner.cancelTask("unknown") // should not throw
131131
})
132+
133+
it("should invoke onTaskError callback when abort throws", async () => {
134+
const onTaskError = vi.fn()
135+
const customRunner = new BackgroundTaskRunner(3, undefined, { onTaskError })
136+
const task = createMockTask("task-1")
137+
task.abortTask.mockRejectedValue(new Error("abort failed"))
138+
customRunner.registerTask(task, "parent-1")
139+
140+
await customRunner.cancelTask("task-1")
141+
142+
expect(onTaskError).toHaveBeenCalledWith("task-1", "parent-1", expect.any(Error))
143+
expect(customRunner.activeCount).toBe(0)
144+
})
132145
})
133146

134147
describe("cancelTasksByParent", () => {
@@ -164,6 +177,18 @@ describe("BackgroundTaskRunner", () => {
164177
expect(task.abortTask).toHaveBeenCalledWith(true)
165178
expect(customRunner.activeCount).toBe(0)
166179
})
180+
181+
it("should invoke onTaskTimeout callback when task times out", async () => {
182+
const onTaskTimeout = vi.fn()
183+
const customRunner = new BackgroundTaskRunner(3, 5000, { onTaskTimeout })
184+
const task = createMockTask("task-1")
185+
customRunner.registerTask(task, "parent-1")
186+
187+
vi.advanceTimersByTime(5000)
188+
await vi.runAllTimersAsync()
189+
190+
expect(onTaskTimeout).toHaveBeenCalledWith("task-1", "parent-1")
191+
})
167192
})
168193

169194
describe("dispose", () => {

src/core/webview/ClineProvider.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ import { ContextProxy } from "../config/ContextProxy"
8080
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
8181
import { CustomModesManager } from "../config/CustomModesManager"
8282
import { Task } from "../task/Task"
83-
import { BackgroundTaskRunner, BACKGROUND_TASK_ALLOWED_TOOLS } from "../task/BackgroundTaskRunner"
83+
import {
84+
BackgroundTaskRunner,
85+
BACKGROUND_TASK_ALLOWED_TOOLS,
86+
BackgroundTaskRunnerCallbacks,
87+
} from "../task/BackgroundTaskRunner"
8488

8589
import { webviewMessageHandler } from "./webviewMessageHandler"
8690
import type { ClineMessage, TodoItem } from "@roo-code/types"
@@ -137,7 +141,14 @@ export class ClineProvider
137141
private recentTasksCache?: string[]
138142
public readonly taskHistoryStore: TaskHistoryStore
139143
private taskHistoryStoreInitialized = false
140-
public readonly backgroundTaskRunner: BackgroundTaskRunner = new BackgroundTaskRunner()
144+
public readonly backgroundTaskRunner: BackgroundTaskRunner = new BackgroundTaskRunner(undefined, undefined, {
145+
onTaskTimeout: (taskId, _parentTaskId) => {
146+
vscode.window.showWarningMessage(`Background task ${taskId} timed out and was cancelled.`)
147+
},
148+
onTaskError: (taskId, _parentTaskId, error) => {
149+
vscode.window.showWarningMessage(`Background task ${taskId} encountered an error: ${error.message}`)
150+
},
151+
})
141152
private globalStateWriteThroughTimer: ReturnType<typeof setTimeout> | null = null
142153
private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds
143154
private pendingOperations: Map<string, PendingEditOperation> = new Map()
@@ -3013,6 +3024,9 @@ export class ClineProvider
30133024
return
30143025
}
30153026

3027+
// Notify the user that the background task finished.
3028+
vscode.window.showInformationMessage(`Background task ${taskId} completed.`)
3029+
30163030
const parentTaskId = info.parentTaskId
30173031
const currentTask = this.getCurrentTask()
30183032

0 commit comments

Comments
 (0)