fix(background-agent): await stale task aborts before poller exits

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-03 17:12:18 +09:00
parent f39a0182eb
commit 4d40b44914
2 changed files with 93 additions and 2 deletions
@@ -16,6 +16,20 @@ describe("checkAndInterruptStaleTasks", () => {
}
const mockNotify = mock(() => Promise.resolve())
function createDeferredPromise(): {
promise: Promise<void>
resolve: () => void
} {
let resolvePromise = () => {}
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve
})
return {
promise,
resolve: resolvePromise,
}
}
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
@@ -114,6 +128,39 @@ describe("checkAndInterruptStaleTasks", () => {
expect(task.error).toContain("no activity")
})
it("should await abort before resolving for no-progress stale interruption", async () => {
//#given
const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
})
const deferred = createDeferredPromise()
mockClient.session.abort.mockImplementationOnce(() => deferred.promise)
//#when
const interruptPromise = checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { messageStalenessTimeoutMs: 600_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
})
let settled = false
void interruptPromise.then(() => {
settled = true
})
await Promise.resolve()
//#then
expect(settled).toBe(false)
deferred.resolve()
await interruptPromise
expect(settled).toBe(true)
})
it("should NOT interrupt tasks with NO progress.lastUpdate that are within messageStalenessTimeoutMs", async () => {
//#given — task started 5 minutes ago, default timeout is 10 minutes
const task = createRunningTask({
@@ -407,6 +454,45 @@ describe("checkAndInterruptStaleTasks", () => {
expect(task.error).toContain("session gone from status registry")
})
it("should await abort before resolving for session-gone interruption", async () => {
//#given
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000),
},
consecutiveMissedPolls: 2,
})
const deferred = createDeferredPromise()
mockClient.session.get.mockRejectedValue(new Error("missing"))
mockClient.session.abort.mockImplementationOnce(() => deferred.promise)
//#when
const interruptPromise = checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: {},
})
let settled = false
void interruptPromise.then(() => {
settled = true
})
await Promise.resolve()
//#then
expect(settled).toBe(false)
deferred.resolve()
await interruptPromise
expect(settled).toBe(true)
})
it("should use session-gone timeout when session is missing from status map (no progress)", async () => {
//#given — task started 2min ago, no progress, session completely gone
const task = createRunningTask({