fix(background-agent): clear toast tracking when tasks stop
🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -1334,6 +1334,33 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
|||||||
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
|
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should remove toast tracking before notifying completed task", async () => {
|
||||||
|
// given
|
||||||
|
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
|
||||||
|
|
||||||
|
const task: BackgroundTask = {
|
||||||
|
id: "task-toast-complete",
|
||||||
|
sessionID: "session-toast-complete",
|
||||||
|
parentSessionID: "parent-toast-complete",
|
||||||
|
parentMessageID: "msg-1",
|
||||||
|
description: "toast completion task",
|
||||||
|
prompt: "test",
|
||||||
|
agent: "explore",
|
||||||
|
status: "running",
|
||||||
|
startedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
await tryCompleteTaskForTest(manager, task)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(removeTaskCalls).toContain(task.id)
|
||||||
|
} finally {
|
||||||
|
resetToastManager()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("should avoid overlapping promptAsync calls when tasks complete concurrently", async () => {
|
test("should avoid overlapping promptAsync calls when tasks complete concurrently", async () => {
|
||||||
// given
|
// given
|
||||||
type PromptAsyncBody = Record<string, unknown> & { noReply?: boolean }
|
type PromptAsyncBody = Record<string, unknown> & { noReply?: boolean }
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { MESSAGE_STORAGE } from "../hook-message-injector"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
import { pruneStaleTasksAndNotifications } from "./task-poller"
|
||||||
import { checkAndInterruptStaleTasks } from "./task-poller"
|
import { checkAndInterruptStaleTasks } from "./task-poller"
|
||||||
|
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||||
|
|
||||||
type OpencodeClient = PluginInput["client"]
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
@@ -384,6 +385,8 @@ export class BackgroundManager {
|
|||||||
existingTask.concurrencyKey = undefined
|
existingTask.concurrencyKey = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeTaskToastTracking(existingTask.id)
|
||||||
|
|
||||||
// Abort the session to prevent infinite polling hang
|
// Abort the session to prevent infinite polling hang
|
||||||
this.client.session.abort({
|
this.client.session.abort({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
@@ -653,6 +656,8 @@ export class BackgroundManager {
|
|||||||
existingTask.concurrencyKey = undefined
|
existingTask.concurrencyKey = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeTaskToastTracking(existingTask.id)
|
||||||
|
|
||||||
// Abort the session to prevent infinite polling hang
|
// Abort the session to prevent infinite polling hang
|
||||||
if (existingTask.sessionID) {
|
if (existingTask.sessionID) {
|
||||||
this.client.session.abort({
|
this.client.session.abort({
|
||||||
@@ -1104,11 +1109,9 @@ export class BackgroundManager {
|
|||||||
SessionCategoryRegistry.remove(task.sessionID)
|
SessionCategoryRegistry.remove(task.sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeTaskToastTracking(task.id)
|
||||||
|
|
||||||
if (options?.skipNotification) {
|
if (options?.skipNotification) {
|
||||||
const toastManager = getTaskToastManager()
|
|
||||||
if (toastManager) {
|
|
||||||
toastManager.removeTask(task.id)
|
|
||||||
}
|
|
||||||
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
|
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1194,6 +1197,8 @@ export class BackgroundManager {
|
|||||||
task.completedAt = new Date()
|
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 })
|
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 })
|
||||||
|
|
||||||
|
removeTaskToastTracking(task.id)
|
||||||
|
|
||||||
// Release concurrency BEFORE any async operations to prevent slot leaks
|
// Release concurrency BEFORE any async operations to prevent slot leaks
|
||||||
if (task.concurrencyKey) {
|
if (task.concurrencyKey) {
|
||||||
this.concurrencyManager.release(task.concurrencyKey)
|
this.concurrencyManager.release(task.concurrencyKey)
|
||||||
@@ -1439,6 +1444,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
|||||||
this.concurrencyManager.release(task.concurrencyKey)
|
this.concurrencyManager.release(task.concurrencyKey)
|
||||||
task.concurrencyKey = undefined
|
task.concurrencyKey = undefined
|
||||||
}
|
}
|
||||||
|
removeTaskToastTracking(task.id)
|
||||||
this.cleanupPendingByParent(task)
|
this.cleanupPendingByParent(task)
|
||||||
if (wasPending) {
|
if (wasPending) {
|
||||||
const key = task.model
|
const key = task.model
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { getTaskToastManager } from "../task-toast-manager"
|
||||||
|
|
||||||
|
export function removeTaskToastTracking(taskId: string): void {
|
||||||
|
const toastManager = getTaskToastManager()
|
||||||
|
if (toastManager) {
|
||||||
|
toastManager.removeTask(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -391,6 +391,31 @@ describe("checkAndInterruptStaleTasks", () => {
|
|||||||
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6")
|
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6")
|
||||||
expect(task.concurrencyKey).toBeUndefined()
|
expect(task.concurrencyKey).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should invoke interruption callback immediately when stale task is cancelled", async () => {
|
||||||
|
//#given
|
||||||
|
const task = createRunningTask({
|
||||||
|
progress: {
|
||||||
|
toolCalls: 1,
|
||||||
|
lastUpdate: new Date(Date.now() - 200_000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const onTaskInterrupted = mock(() => {})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await checkAndInterruptStaleTasks({
|
||||||
|
tasks: [task],
|
||||||
|
client: mockClient as never,
|
||||||
|
config: { staleTimeoutMs: 180_000 },
|
||||||
|
concurrencyManager: mockConcurrencyManager as never,
|
||||||
|
notifyParentSession: mockNotify,
|
||||||
|
onTaskInterrupted,
|
||||||
|
})
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(task.status).toBe("cancelled")
|
||||||
|
expect(onTaskInterrupted).toHaveBeenCalledWith(task)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("pruneStaleTasksAndNotifications", () => {
|
describe("pruneStaleTasksAndNotifications", () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
MIN_RUNTIME_BEFORE_STALE_MS,
|
MIN_RUNTIME_BEFORE_STALE_MS,
|
||||||
TASK_TTL_MS,
|
TASK_TTL_MS,
|
||||||
} from "./constants"
|
} from "./constants"
|
||||||
|
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
|
||||||
|
|
||||||
export function pruneStaleTasksAndNotifications(args: {
|
export function pruneStaleTasksAndNotifications(args: {
|
||||||
tasks: Map<string, BackgroundTask>
|
tasks: Map<string, BackgroundTask>
|
||||||
@@ -66,8 +67,17 @@ export async function checkAndInterruptStaleTasks(args: {
|
|||||||
concurrencyManager: ConcurrencyManager
|
concurrencyManager: ConcurrencyManager
|
||||||
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
notifyParentSession: (task: BackgroundTask) => Promise<void>
|
||||||
sessionStatuses?: SessionStatusMap
|
sessionStatuses?: SessionStatusMap
|
||||||
|
onTaskInterrupted?: (task: BackgroundTask) => void
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { tasks, client, config, concurrencyManager, notifyParentSession, sessionStatuses } = args
|
const {
|
||||||
|
tasks,
|
||||||
|
client,
|
||||||
|
config,
|
||||||
|
concurrencyManager,
|
||||||
|
notifyParentSession,
|
||||||
|
sessionStatuses,
|
||||||
|
onTaskInterrupted = (task) => removeTaskToastTracking(task.id),
|
||||||
|
} = args
|
||||||
const staleTimeoutMs = config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
|
const staleTimeoutMs = config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
@@ -98,6 +108,8 @@ export async function checkAndInterruptStaleTasks(args: {
|
|||||||
task.concurrencyKey = undefined
|
task.concurrencyKey = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTaskInterrupted(task)
|
||||||
|
|
||||||
client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||||
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
|
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
|
||||||
|
|
||||||
@@ -127,6 +139,8 @@ export async function checkAndInterruptStaleTasks(args: {
|
|||||||
task.concurrencyKey = undefined
|
task.concurrencyKey = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTaskInterrupted(task)
|
||||||
|
|
||||||
client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||||
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
|
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user