fix(background-agent): fail cancellation when abort fails

This commit is contained in:
YeonGyu-Kim
2026-05-21 15:50:28 +09:00
parent bd1a6e3d3b
commit 6d15ab86ab
3 changed files with 64 additions and 12 deletions
@@ -11,11 +11,14 @@ afterEach(() => {
while (managersToShutdown.length > 0) managersToShutdown.pop()?.shutdown()
})
function createBackgroundManager(config?: { defaultConcurrency?: number }): BackgroundManager {
function createBackgroundManager(
config?: { defaultConcurrency?: number },
abortSession: () => Promise<unknown> = async () => ({ data: true }),
): BackgroundManager {
const directory = tmpdir()
const client = { session: {} as PluginInput["client"]["session"] } as PluginInput["client"]
Reflect.set(client.session, "abort", async () => ({ data: true }))
Reflect.set(client.session, "abort", abortSession)
Reflect.set(client.session, "create", async () => ({ data: { id: `session-${crypto.randomUUID().slice(0, 8)}` } }))
Reflect.set(client.session, "get", async () => ({ data: { directory } }))
Reflect.set(client.session, "messages", async () => ({ data: [] }))
@@ -111,6 +114,31 @@ describe("BackgroundManager.cancelTask cleanup", () => {
expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId)
})
test("#given running task abort returns SDK error #when cancelTask runs #then cancellation fails and task stays running", async () => {
// given
const manager = createBackgroundManager(undefined, async () => ({ error: { message: "session still active" } }))
const task = createMockTask({
id: "task-abort-error",
parentSessionId: "parent-session-abort-error",
sessionId: "session-abort-error",
})
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
// when
const cancelled = await manager.cancelTask(task.id, {
skipNotification: true,
source: "test",
})
// then
expect(cancelled).toBe(false)
expect(task.status).toBe("running")
expect(getTaskMap(manager).get(task.id)).toBe(task)
expect(getPendingByParent(manager).get(task.parentSessionId)).toEqual(new Set([task.id]))
})
test("#given a running task #when cancelTask called with skipNotification=false #then task is also eventually removed", async () => {
// given
const manager = createBackgroundManager()
+16 -10
View File
@@ -301,14 +301,21 @@ export class BackgroundManager {
this.registerProcessCleanup()
}
private async abortSessionWithLogging(sessionID: string, reason: string): Promise<void> {
private async abortSessionWithLogging(sessionID: string, reason: string): Promise<boolean> {
try {
await abortWithTimeout(this.client, sessionID)
const aborted = await abortWithTimeout(this.client, sessionID)
if (!aborted) {
log(`[background-agent] Session abort did not complete during ${reason}:`, {
sessionID,
})
}
return aborted
} catch (error) {
log(`[background-agent] Failed to abort session during ${reason}:`, {
sessionID,
error,
})
return false
}
}
@@ -2179,6 +2186,13 @@ The task was re-queued on a fallback model after a retryable failure.
}
const wasRunning = task.status === "running"
if (wasRunning && abortSession && task.sessionId) {
const aborted = await this.abortSessionWithLogging(task.sessionId, `task cancellation (${source})`)
if (!aborted) return false
clearDelegatedChildSessionBootstrap(task.sessionId)
SessionCategoryRegistry.remove(task.sessionId)
}
if (task.currentAttemptID) {
finalizeAttempt(task, task.currentAttemptID, "cancelled", reason)
} else {
@@ -2210,14 +2224,6 @@ The task was re-queued on a fallback model after a retryable failure.
this.idleDeferralTimers.delete(task.id)
}
if (abortSession && task.sessionId) {
// Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
await this.abortSessionWithLogging(task.sessionId, `task cancellation (${source})`)
clearDelegatedChildSessionBootstrap(task.sessionId)
SessionCategoryRegistry.remove(task.sessionId)
}
removeTaskToastTracking(task.id)
// Update continuation marker for CLI run mode
+18
View File
@@ -408,6 +408,24 @@ describe("background_cancel", () => {
expect(output).toContain("Task cancelled successfully")
})
test("reports an error when manager cannot cancel a running task", async () => {
// #given
const task = createTask({ status: "running" })
const manager = unsafeTestValue<BackgroundManager>({
getTask: (id: string) => (id === task.id ? task : undefined),
getAllDescendantTasks: () => [task],
cancelTask: async () => false,
})
const client = { session: { abort: async () => ({}) } } as BackgroundCancelClient
const tool = createBackgroundCancel(manager, client)
// #when
const output = await tool.execute({ taskId: task.id }, mockContext)
// #then
expect(output).toContain(`[ERROR] Failed to cancel task: ${task.id}`)
})
test("cancels all running or pending tasks", async () => {
// #given
const taskA = createTask({ id: "task-a", status: "running" })