193 lines
5.5 KiB
TypeScript
193 lines
5.5 KiB
TypeScript
|
|
import { describe, it, expect, mock } from "bun:test"
|
||
|
|
|
||
|
|
import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications } from "./task-poller"
|
||
|
|
import type { BackgroundTask } from "./types"
|
||
|
|
|
||
|
|
describe("checkAndInterruptStaleTasks", () => {
|
||
|
|
const mockClient = {
|
||
|
|
session: {
|
||
|
|
abort: mock(() => Promise.resolve()),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
const mockConcurrencyManager = {
|
||
|
|
release: mock(() => {}),
|
||
|
|
}
|
||
|
|
const mockNotify = mock(() => Promise.resolve())
|
||
|
|
|
||
|
|
function createRunningTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
||
|
|
return {
|
||
|
|
id: "task-1",
|
||
|
|
sessionID: "ses-1",
|
||
|
|
parentSessionID: "parent-ses-1",
|
||
|
|
parentMessageID: "msg-1",
|
||
|
|
description: "test",
|
||
|
|
prompt: "test",
|
||
|
|
agent: "explore",
|
||
|
|
status: "running",
|
||
|
|
startedAt: new Date(Date.now() - 120_000),
|
||
|
|
...overrides,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
it("should interrupt tasks with lastUpdate exceeding stale timeout", async () => {
|
||
|
|
//#given
|
||
|
|
const task = createRunningTask({
|
||
|
|
progress: {
|
||
|
|
toolCalls: 1,
|
||
|
|
lastUpdate: new Date(Date.now() - 200_000),
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
//#when
|
||
|
|
await checkAndInterruptStaleTasks({
|
||
|
|
tasks: [task],
|
||
|
|
client: mockClient as never,
|
||
|
|
config: { staleTimeoutMs: 180_000 },
|
||
|
|
concurrencyManager: mockConcurrencyManager as never,
|
||
|
|
notifyParentSession: mockNotify,
|
||
|
|
})
|
||
|
|
|
||
|
|
//#then
|
||
|
|
expect(task.status).toBe("cancelled")
|
||
|
|
expect(task.error).toContain("Stale timeout")
|
||
|
|
})
|
||
|
|
|
||
|
|
it("should NOT interrupt tasks with recent lastUpdate", async () => {
|
||
|
|
//#given
|
||
|
|
const task = createRunningTask({
|
||
|
|
progress: {
|
||
|
|
toolCalls: 1,
|
||
|
|
lastUpdate: new Date(Date.now() - 10_000),
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
//#when
|
||
|
|
await checkAndInterruptStaleTasks({
|
||
|
|
tasks: [task],
|
||
|
|
client: mockClient as never,
|
||
|
|
config: { staleTimeoutMs: 180_000 },
|
||
|
|
concurrencyManager: mockConcurrencyManager as never,
|
||
|
|
notifyParentSession: mockNotify,
|
||
|
|
})
|
||
|
|
|
||
|
|
//#then
|
||
|
|
expect(task.status).toBe("running")
|
||
|
|
})
|
||
|
|
|
||
|
|
it("should interrupt tasks with NO progress.lastUpdate that exceeded messageStalenessTimeoutMs since startedAt", async () => {
|
||
|
|
//#given — task started 15 minutes ago, never received any progress update
|
||
|
|
const task = createRunningTask({
|
||
|
|
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||
|
|
progress: undefined,
|
||
|
|
})
|
||
|
|
|
||
|
|
//#when
|
||
|
|
await checkAndInterruptStaleTasks({
|
||
|
|
tasks: [task],
|
||
|
|
client: mockClient as never,
|
||
|
|
config: { messageStalenessTimeoutMs: 600_000 },
|
||
|
|
concurrencyManager: mockConcurrencyManager as never,
|
||
|
|
notifyParentSession: mockNotify,
|
||
|
|
})
|
||
|
|
|
||
|
|
//#then
|
||
|
|
expect(task.status).toBe("cancelled")
|
||
|
|
expect(task.error).toContain("no activity")
|
||
|
|
})
|
||
|
|
|
||
|
|
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({
|
||
|
|
startedAt: new Date(Date.now() - 5 * 60 * 1000),
|
||
|
|
progress: undefined,
|
||
|
|
})
|
||
|
|
|
||
|
|
//#when
|
||
|
|
await checkAndInterruptStaleTasks({
|
||
|
|
tasks: [task],
|
||
|
|
client: mockClient as never,
|
||
|
|
config: { messageStalenessTimeoutMs: 600_000 },
|
||
|
|
concurrencyManager: mockConcurrencyManager as never,
|
||
|
|
notifyParentSession: mockNotify,
|
||
|
|
})
|
||
|
|
|
||
|
|
//#then
|
||
|
|
expect(task.status).toBe("running")
|
||
|
|
})
|
||
|
|
|
||
|
|
it("should use DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS when messageStalenessTimeoutMs is not configured", async () => {
|
||
|
|
//#given — task started 15 minutes ago, no config for messageStalenessTimeoutMs
|
||
|
|
const task = createRunningTask({
|
||
|
|
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||
|
|
progress: undefined,
|
||
|
|
})
|
||
|
|
|
||
|
|
//#when — default is 10 minutes (600_000ms)
|
||
|
|
await checkAndInterruptStaleTasks({
|
||
|
|
tasks: [task],
|
||
|
|
client: mockClient as never,
|
||
|
|
config: undefined,
|
||
|
|
concurrencyManager: mockConcurrencyManager as never,
|
||
|
|
notifyParentSession: mockNotify,
|
||
|
|
})
|
||
|
|
|
||
|
|
//#then
|
||
|
|
expect(task.status).toBe("cancelled")
|
||
|
|
expect(task.error).toContain("no activity")
|
||
|
|
})
|
||
|
|
|
||
|
|
it("should release concurrency key when interrupting a never-updated task", async () => {
|
||
|
|
//#given
|
||
|
|
const releaseMock = mock(() => {})
|
||
|
|
const task = createRunningTask({
|
||
|
|
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
||
|
|
progress: undefined,
|
||
|
|
concurrencyKey: "anthropic/claude-opus-4-6",
|
||
|
|
})
|
||
|
|
|
||
|
|
//#when
|
||
|
|
await checkAndInterruptStaleTasks({
|
||
|
|
tasks: [task],
|
||
|
|
client: mockClient as never,
|
||
|
|
config: { messageStalenessTimeoutMs: 600_000 },
|
||
|
|
concurrencyManager: { release: releaseMock } as never,
|
||
|
|
notifyParentSession: mockNotify,
|
||
|
|
})
|
||
|
|
|
||
|
|
//#then
|
||
|
|
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6")
|
||
|
|
expect(task.concurrencyKey).toBeUndefined()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe("pruneStaleTasksAndNotifications", () => {
|
||
|
|
it("should prune tasks that exceeded TTL", () => {
|
||
|
|
//#given
|
||
|
|
const tasks = new Map<string, BackgroundTask>()
|
||
|
|
const oldTask: BackgroundTask = {
|
||
|
|
id: "old-task",
|
||
|
|
parentSessionID: "parent",
|
||
|
|
parentMessageID: "msg",
|
||
|
|
description: "old",
|
||
|
|
prompt: "old",
|
||
|
|
agent: "explore",
|
||
|
|
status: "running",
|
||
|
|
startedAt: new Date(Date.now() - 31 * 60 * 1000),
|
||
|
|
}
|
||
|
|
tasks.set("old-task", oldTask)
|
||
|
|
|
||
|
|
const pruned: string[] = []
|
||
|
|
const notifications = new Map<string, BackgroundTask[]>()
|
||
|
|
|
||
|
|
//#when
|
||
|
|
pruneStaleTasksAndNotifications({
|
||
|
|
tasks,
|
||
|
|
notifications,
|
||
|
|
onTaskPruned: (taskId) => pruned.push(taskId),
|
||
|
|
})
|
||
|
|
|
||
|
|
//#then
|
||
|
|
expect(pruned).toContain("old-task")
|
||
|
|
})
|
||
|
|
})
|