2026-03-11 18:20:13 +09:00
|
|
|
declare const require: (name: string) => any
|
2026-03-23 22:17:03 +09:00
|
|
|
const { describe, it, expect, mock, spyOn, beforeEach, afterEach } = require("bun:test")
|
2026-02-14 14:56:51 +09:00
|
|
|
|
|
|
|
|
import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications } from "./task-poller"
|
|
|
|
|
import type { BackgroundTask } from "./types"
|
|
|
|
|
|
|
|
|
|
describe("checkAndInterruptStaleTasks", () => {
|
|
|
|
|
const mockClient = {
|
|
|
|
|
session: {
|
|
|
|
|
abort: mock(() => Promise.resolve()),
|
2026-03-28 15:24:18 +09:00
|
|
|
get: mock(() => Promise.resolve({ data: { id: "ses-1" } })),
|
2026-02-14 14:56:51 +09:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
const mockConcurrencyManager = {
|
|
|
|
|
release: mock(() => {}),
|
|
|
|
|
}
|
|
|
|
|
const mockNotify = mock(() => Promise.resolve())
|
|
|
|
|
|
2026-04-03 17:12:18 +09:00
|
|
|
function createDeferredPromise(): {
|
|
|
|
|
promise: Promise<void>
|
|
|
|
|
resolve: () => void
|
|
|
|
|
} {
|
|
|
|
|
let resolvePromise = () => {}
|
|
|
|
|
const promise = new Promise<void>((resolve) => {
|
|
|
|
|
resolvePromise = resolve
|
|
|
|
|
})
|
|
|
|
|
return {
|
|
|
|
|
promise,
|
|
|
|
|
resolve: resolvePromise,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:56:51 +09:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-23 22:17:03 +09:00
|
|
|
const originalDateNow = Date.now
|
|
|
|
|
let fixedTime: number
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
fixedTime = Date.now()
|
|
|
|
|
spyOn(globalThis.Date, "now").mockReturnValue(fixedTime)
|
2026-03-28 15:24:18 +09:00
|
|
|
mockClient.session.abort.mockClear()
|
|
|
|
|
mockClient.session.get.mockReset()
|
|
|
|
|
mockClient.session.get.mockResolvedValue({ data: { id: "ses-1" } })
|
|
|
|
|
mockConcurrencyManager.release.mockClear()
|
|
|
|
|
mockNotify.mockClear()
|
2026-03-23 22:17:03 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
Date.now = originalDateNow
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-14 14:56:51 +09:00
|
|
|
|
|
|
|
|
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 () => {
|
2026-04-04 14:27:07 +09:00
|
|
|
//#given - task started 15 minutes ago, never received any progress update
|
2026-02-14 14:56:51 +09:00
|
|
|
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")
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-03 17:12:18 +09:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-14 14:56:51 +09:00
|
|
|
it("should NOT interrupt tasks with NO progress.lastUpdate that are within messageStalenessTimeoutMs", async () => {
|
2026-04-04 14:27:07 +09:00
|
|
|
//#given - task started 5 minutes ago, default timeout is 10 minutes
|
2026-02-14 14:56:51 +09:00
|
|
|
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 () => {
|
2026-04-04 14:27:07 +09:00
|
|
|
//#given - task started 65 minutes ago, no config for messageStalenessTimeoutMs
|
2026-02-14 14:56:51 +09:00
|
|
|
const task = createRunningTask({
|
2026-03-23 21:00:59 +09:00
|
|
|
startedAt: new Date(Date.now() - 65 * 60 * 1000),
|
2026-02-14 14:56:51 +09:00
|
|
|
progress: undefined,
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#when - default is 60 minutes (3_600_000ms)
|
2026-02-14 14:56:51 +09:00
|
|
|
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")
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-14 17:59:01 +09:00
|
|
|
it("should NOT interrupt task when session is running, even if lastUpdate exceeds stale timeout", async () => {
|
2026-04-04 14:27:07 +09:00
|
|
|
//#given - lastUpdate is 5min old but session is actively running
|
2026-02-14 17:59:01 +09:00
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 2,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 300_000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#when - session status is "busy" (OpenCode's actual status for active LLM processing)
|
2026-02-14 17:59:01 +09:00
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
2026-02-15 14:24:45 +09:00
|
|
|
sessionStatuses: { "ses-1": { type: "busy" } },
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#then - task should survive because session is actively busy
|
2026-02-15 14:24:45 +09:00
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should NOT interrupt busy session task even with very old lastUpdate", async () => {
|
2026-04-04 14:27:07 +09:00
|
|
|
//#given - lastUpdate is 15min old, but session is still busy
|
2026-02-15 14:24:45 +09:00
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 900_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 2,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 900_000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#when - session busy, lastUpdate far exceeds any timeout
|
2026-02-15 14:24:45 +09:00
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000, messageStalenessTimeoutMs: 600_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "busy" } },
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#then - busy sessions are NEVER stale-killed (babysitter + TTL prune handle these)
|
2026-02-15 14:24:45 +09:00
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should NOT interrupt busy session even with no progress (undefined lastUpdate)", async () => {
|
2026-04-04 14:27:07 +09:00
|
|
|
//#given - task has no progress at all, but session is busy
|
2026-02-15 14:24:45 +09:00
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
|
|
|
|
progress: undefined,
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#when - session is busy
|
2026-02-15 14:24:45 +09:00
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { messageStalenessTimeoutMs: 600_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "busy" } },
|
2026-02-14 17:59:01 +09:00
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#then - task should survive because session is actively running
|
2026-02-14 17:59:01 +09:00
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
|
2026-04-04 14:27:07 +09:00
|
|
|
//#given - lastUpdate is 5min old and session is idle
|
2026-02-14 17:59:01 +09:00
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 2,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 300_000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#when - session status is "idle"
|
2026-02-14 17:59:01 +09:00
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "idle" } },
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#then - task should be killed because session is idle with stale lastUpdate
|
2026-02-14 17:59:01 +09:00
|
|
|
expect(task.status).toBe("cancelled")
|
|
|
|
|
expect(task.error).toContain("Stale timeout")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should NOT interrupt running session task even with very old lastUpdate", async () => {
|
2026-04-04 14:27:07 +09:00
|
|
|
//#given - lastUpdate is 15min old, but session is still running
|
2026-02-14 17:59:01 +09:00
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 900_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 2,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 900_000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#when - session running, lastUpdate far exceeds any timeout
|
2026-02-14 17:59:01 +09:00
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000, messageStalenessTimeoutMs: 600_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "running" } },
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-04 14:27:07 +09:00
|
|
|
//#then - running sessions are NEVER stale-killed (babysitter + TTL prune handle these)
|
2026-02-14 17:59:01 +09:00
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should NOT interrupt running session even with no progress (undefined lastUpdate)", async () => {
|
2026-04-04 14:27:07 +09:00
|
|
|
//#given - task has no progress at all, but session is running
|
2026-02-14 17:59:01 +09:00
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
|
|
|
|
progress: undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when — session is running
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { messageStalenessTimeoutMs: 600_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "running" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then — running sessions are NEVER killed, even without progress
|
|
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-28 15:24:18 +09:00
|
|
|
it("should NOT cancel healthy task on first missing status poll", async () => {
|
|
|
|
|
//#given — one missing poll should not be enough to declare the session gone
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 1,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 120_000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: {},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
expect(task.consecutiveMissedPolls).toBe(1)
|
|
|
|
|
expect(mockClient.session.get).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should NOT cancel task when session.get confirms the session still exists", async () => {
|
|
|
|
|
//#given — repeated missing polls but direct lookup still succeeds
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 1,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 120_000),
|
|
|
|
|
},
|
|
|
|
|
consecutiveMissedPolls: 2,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
2026-03-31 17:04:07 -07:00
|
|
|
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: {},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
expect(task.consecutiveMissedPolls).toBe(0)
|
|
|
|
|
expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: "ses-1" } })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should NOT cancel task when session.get returns a transient error response", async () => {
|
|
|
|
|
//#given — repeated missing polls but lookup failed with a retryable transport error
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 1,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 120_000),
|
|
|
|
|
},
|
|
|
|
|
consecutiveMissedPolls: 2,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
mockClient.session.get.mockResolvedValue({
|
|
|
|
|
error: { message: "Network timeout", status: 500 },
|
|
|
|
|
data: undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
2026-03-28 15:24:18 +09:00
|
|
|
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: {},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
expect(task.consecutiveMissedPolls).toBe(0)
|
|
|
|
|
expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: "ses-1" } })
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-27 15:48:07 +09:00
|
|
|
it("should use session-gone timeout when session is missing from status map (with progress)", async () => {
|
|
|
|
|
//#given — lastUpdate 2min ago, session completely gone from status
|
2026-02-14 17:59:01 +09:00
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 1,
|
2026-03-27 15:48:07 +09:00
|
|
|
lastUpdate: new Date(Date.now() - 120_000),
|
2026-02-14 17:59:01 +09:00
|
|
|
},
|
2026-03-28 15:24:18 +09:00
|
|
|
consecutiveMissedPolls: 2,
|
2026-02-14 17:59:01 +09:00
|
|
|
})
|
|
|
|
|
|
2026-03-28 15:24:18 +09:00
|
|
|
mockClient.session.get.mockRejectedValue(new Error("missing"))
|
|
|
|
|
|
2026-03-27 15:48:07 +09:00
|
|
|
//#when — empty sessionStatuses (session gone), sessionGoneTimeoutMs = 60s
|
2026-02-14 17:59:01 +09:00
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
2026-03-27 15:48:07 +09:00
|
|
|
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
|
2026-02-14 17:59:01 +09:00
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: {},
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-27 15:48:07 +09:00
|
|
|
//#then — cancelled because session gone timeout (60s) < timeSinceLastUpdate (120s)
|
2026-02-14 17:59:01 +09:00
|
|
|
expect(task.status).toBe("cancelled")
|
2026-03-27 15:48:07 +09:00
|
|
|
expect(task.error).toContain("session gone from status registry")
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-03 17:12:18 +09:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-27 15:48:07 +09:00
|
|
|
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({
|
|
|
|
|
startedAt: new Date(Date.now() - 120_000),
|
|
|
|
|
progress: undefined,
|
2026-03-28 15:24:18 +09:00
|
|
|
consecutiveMissedPolls: 2,
|
2026-03-27 15:48:07 +09:00
|
|
|
})
|
|
|
|
|
|
2026-03-28 15:24:18 +09:00
|
|
|
mockClient.session.get.mockRejectedValue(new Error("missing"))
|
|
|
|
|
|
2026-03-27 15:48:07 +09:00
|
|
|
//#when — session gone, sessionGoneTimeoutMs = 60s
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { messageStalenessTimeoutMs: 600_000, sessionGoneTimeoutMs: 60_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: {},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then — cancelled because session gone timeout (60s) < runtime (120s)
|
|
|
|
|
expect(task.status).toBe("cancelled")
|
|
|
|
|
expect(task.error).toContain("session gone from status registry")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should NOT use session-gone timeout when session is idle (present in status map)", async () => {
|
|
|
|
|
//#given — lastUpdate 2min ago, session is idle (present in status but not active)
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 1,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 120_000),
|
|
|
|
|
},
|
2026-03-28 15:24:18 +09:00
|
|
|
consecutiveMissedPolls: 2,
|
2026-03-27 15:48:07 +09:00
|
|
|
})
|
|
|
|
|
|
2026-03-28 15:24:18 +09:00
|
|
|
mockClient.session.get.mockRejectedValue(new Error("missing"))
|
|
|
|
|
|
2026-03-27 15:48:07 +09:00
|
|
|
//#when — session is idle (present in map), staleTimeoutMs = 180s
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "idle" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then — still running because normal staleTimeout (180s) > timeSinceLastUpdate (120s)
|
|
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should use default session-gone timeout when not configured", async () => {
|
|
|
|
|
//#given — lastUpdate 2min ago, session gone, no sessionGoneTimeoutMs config
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 1,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 120_000),
|
|
|
|
|
},
|
2026-03-28 15:24:18 +09:00
|
|
|
consecutiveMissedPolls: 2,
|
2026-03-27 15:48:07 +09:00
|
|
|
})
|
|
|
|
|
|
2026-03-28 15:24:18 +09:00
|
|
|
mockClient.session.get.mockRejectedValue(new Error("missing"))
|
|
|
|
|
|
2026-03-27 15:48:07 +09:00
|
|
|
//#when — no config (default sessionGoneTimeoutMs = 60_000)
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: undefined,
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: {},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then — cancelled because default session gone timeout (60s) < timeSinceLastUpdate (120s)
|
|
|
|
|
expect(task.status).toBe("cancelled")
|
|
|
|
|
expect(task.error).toContain("session gone from status registry")
|
2026-02-14 17:59:01 +09:00
|
|
|
})
|
|
|
|
|
|
2026-02-15 14:24:45 +09:00
|
|
|
it("should NOT interrupt task when session is busy (OpenCode status), even if lastUpdate exceeds stale timeout", async () => {
|
|
|
|
|
//#given — lastUpdate is 5min old but session is "busy" (OpenCode's actual status for active sessions)
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 2,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 300_000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when — session status is "busy" (not "running" — OpenCode uses "busy" for active LLM processing)
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "busy" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then — "busy" sessions must be protected from stale-kill
|
|
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should NOT interrupt task when session is in retry state", async () => {
|
|
|
|
|
//#given — lastUpdate is 5min old but session is retrying
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 1,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 300_000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when — session status is "retry" (OpenCode retries on transient API errors)
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "retry" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then — retry sessions must be protected from stale-kill
|
|
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should NOT interrupt busy session even with no progress (undefined lastUpdate)", async () => {
|
|
|
|
|
//#given — no progress at all, session is "busy" (thinking model with no streamed tokens yet)
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 15 * 60 * 1000),
|
|
|
|
|
progress: undefined,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when — session is busy
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { messageStalenessTimeoutMs: 600_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "busy" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then — busy sessions with no progress must survive
|
|
|
|
|
expect(task.status).toBe("running")
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-14 14:56:51 +09:00
|
|
|
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,
|
2026-04-17 14:51:52 +09:00
|
|
|
concurrencyKey: "anthropic/claude-opus-4-7",
|
2026-02-14 14:56:51 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { messageStalenessTimeoutMs: 600_000 },
|
|
|
|
|
concurrencyManager: { release: releaseMock } as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
2026-04-17 14:51:52 +09:00
|
|
|
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-7")
|
2026-02-14 14:56:51 +09:00
|
|
|
expect(task.concurrencyKey).toBeUndefined()
|
|
|
|
|
})
|
2026-03-09 11:39:04 +09:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
2026-03-18 13:56:11 +09:00
|
|
|
|
|
|
|
|
it('should NOT protect task when session has terminal non-idle status like "interrupted"', async () => {
|
|
|
|
|
//#given — lastUpdate is 5min old, session is "interrupted" (terminal, not active)
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 2,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 300_000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when — session status is "interrupted" (terminal)
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "interrupted" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then — terminal statuses should not protect from stale timeout
|
|
|
|
|
expect(task.status).toBe("cancelled")
|
|
|
|
|
expect(task.error).toContain("Stale timeout")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('should NOT protect task when session has unknown status type', async () => {
|
|
|
|
|
//#given — lastUpdate is 5min old, session has an unknown status
|
|
|
|
|
const task = createRunningTask({
|
|
|
|
|
startedAt: new Date(Date.now() - 300_000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 2,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 300_000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when — session has unknown status type
|
|
|
|
|
await checkAndInterruptStaleTasks({
|
|
|
|
|
tasks: [task],
|
|
|
|
|
client: mockClient as never,
|
|
|
|
|
config: { staleTimeoutMs: 180_000 },
|
|
|
|
|
concurrencyManager: mockConcurrencyManager as never,
|
|
|
|
|
notifyParentSession: mockNotify,
|
|
|
|
|
sessionStatuses: { "ses-1": { type: "some-weird-status" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then — unknown statuses should not protect from stale timeout
|
|
|
|
|
expect(task.status).toBe("cancelled")
|
|
|
|
|
expect(task.error).toContain("Stale timeout")
|
|
|
|
|
})
|
2026-02-14 14:56:51 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("pruneStaleTasksAndNotifications", () => {
|
2026-03-11 21:52:48 +09:00
|
|
|
function createTerminalTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
|
|
|
|
return {
|
|
|
|
|
id: "terminal-task",
|
|
|
|
|
parentSessionID: "parent",
|
|
|
|
|
parentMessageID: "msg",
|
|
|
|
|
description: "terminal",
|
|
|
|
|
prompt: "terminal",
|
|
|
|
|
agent: "explore",
|
|
|
|
|
status: "completed",
|
|
|
|
|
startedAt: new Date(Date.now() - 40 * 60 * 1000),
|
|
|
|
|
completedAt: new Date(Date.now() - 31 * 60 * 1000),
|
|
|
|
|
...overrides,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:56:51 +09:00
|
|
|
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")
|
|
|
|
|
})
|
2026-03-11 18:20:13 +09:00
|
|
|
|
2026-03-27 16:06:38 +09:00
|
|
|
it("#given running task with recent progress #when startedAt exceeds TTL #then should NOT prune", () => {
|
|
|
|
|
//#given
|
|
|
|
|
const tasks = new Map<string, BackgroundTask>()
|
|
|
|
|
const activeTask: BackgroundTask = {
|
|
|
|
|
id: "active-task",
|
|
|
|
|
parentSessionID: "parent",
|
|
|
|
|
parentMessageID: "msg",
|
|
|
|
|
description: "active",
|
|
|
|
|
prompt: "active",
|
|
|
|
|
agent: "oracle",
|
|
|
|
|
status: "running",
|
|
|
|
|
startedAt: new Date(Date.now() - 45 * 60 * 1000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 10,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 5 * 60 * 1000),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
tasks.set("active-task", activeTask)
|
|
|
|
|
|
|
|
|
|
const pruned: string[] = []
|
|
|
|
|
const notifications = new Map<string, BackgroundTask[]>()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
pruneStaleTasksAndNotifications({
|
|
|
|
|
tasks,
|
|
|
|
|
notifications,
|
|
|
|
|
onTaskPruned: (taskId) => pruned.push(taskId),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(pruned).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("#given running task with stale progress #when lastUpdate exceeds TTL #then should prune", () => {
|
|
|
|
|
//#given
|
|
|
|
|
const tasks = new Map<string, BackgroundTask>()
|
|
|
|
|
const staleTask: BackgroundTask = {
|
|
|
|
|
id: "stale-task",
|
|
|
|
|
parentSessionID: "parent",
|
|
|
|
|
parentMessageID: "msg",
|
|
|
|
|
description: "stale",
|
|
|
|
|
prompt: "stale",
|
|
|
|
|
agent: "oracle",
|
|
|
|
|
status: "running",
|
|
|
|
|
startedAt: new Date(Date.now() - 60 * 60 * 1000),
|
|
|
|
|
progress: {
|
|
|
|
|
toolCalls: 10,
|
|
|
|
|
lastUpdate: new Date(Date.now() - 35 * 60 * 1000),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
tasks.set("stale-task", staleTask)
|
|
|
|
|
|
|
|
|
|
const pruned: string[] = []
|
|
|
|
|
const notifications = new Map<string, BackgroundTask[]>()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
pruneStaleTasksAndNotifications({
|
|
|
|
|
tasks,
|
|
|
|
|
notifications,
|
|
|
|
|
onTaskPruned: (taskId) => pruned.push(taskId),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(pruned).toContain("stale-task")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("#given custom taskTtlMs #when task exceeds custom TTL #then should prune", () => {
|
|
|
|
|
//#given
|
|
|
|
|
const tasks = new Map<string, BackgroundTask>()
|
|
|
|
|
const task: BackgroundTask = {
|
|
|
|
|
id: "custom-ttl-task",
|
|
|
|
|
parentSessionID: "parent",
|
|
|
|
|
parentMessageID: "msg",
|
|
|
|
|
description: "custom",
|
|
|
|
|
prompt: "custom",
|
|
|
|
|
agent: "explore",
|
|
|
|
|
status: "running",
|
|
|
|
|
startedAt: new Date(Date.now() - 61 * 60 * 1000),
|
|
|
|
|
}
|
|
|
|
|
tasks.set("custom-ttl-task", task)
|
|
|
|
|
|
|
|
|
|
const pruned: string[] = []
|
|
|
|
|
const notifications = new Map<string, BackgroundTask[]>()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
pruneStaleTasksAndNotifications({
|
|
|
|
|
tasks,
|
|
|
|
|
notifications,
|
|
|
|
|
taskTtlMs: 60 * 60 * 1000,
|
|
|
|
|
onTaskPruned: (taskId) => pruned.push(taskId),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(pruned).toContain("custom-ttl-task")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("#given custom taskTtlMs #when task within custom TTL #then should NOT prune", () => {
|
|
|
|
|
//#given
|
|
|
|
|
const tasks = new Map<string, BackgroundTask>()
|
|
|
|
|
const task: BackgroundTask = {
|
|
|
|
|
id: "within-ttl-task",
|
|
|
|
|
parentSessionID: "parent",
|
|
|
|
|
parentMessageID: "msg",
|
|
|
|
|
description: "within",
|
|
|
|
|
prompt: "within",
|
|
|
|
|
agent: "explore",
|
|
|
|
|
status: "running",
|
|
|
|
|
startedAt: new Date(Date.now() - 45 * 60 * 1000),
|
|
|
|
|
}
|
|
|
|
|
tasks.set("within-ttl-task", task)
|
|
|
|
|
|
|
|
|
|
const pruned: string[] = []
|
|
|
|
|
const notifications = new Map<string, BackgroundTask[]>()
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
pruneStaleTasksAndNotifications({
|
|
|
|
|
tasks,
|
|
|
|
|
notifications,
|
|
|
|
|
taskTtlMs: 60 * 60 * 1000,
|
|
|
|
|
onTaskPruned: (taskId) => pruned.push(taskId),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(pruned).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-11 21:52:48 +09:00
|
|
|
it("should prune terminal tasks when completion time exceeds terminal TTL", () => {
|
2026-03-11 18:20:13 +09:00
|
|
|
//#given
|
|
|
|
|
const tasks = new Map<string, BackgroundTask>()
|
|
|
|
|
const terminalStatuses: BackgroundTask["status"][] = ["completed", "error", "cancelled", "interrupt"]
|
|
|
|
|
|
|
|
|
|
for (const status of terminalStatuses) {
|
2026-03-11 21:52:48 +09:00
|
|
|
tasks.set(status, createTerminalTask({
|
2026-03-11 18:20:13 +09:00
|
|
|
id: status,
|
|
|
|
|
description: status,
|
|
|
|
|
prompt: status,
|
|
|
|
|
status,
|
2026-03-11 21:52:48 +09:00
|
|
|
}))
|
2026-03-11 18:20:13 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pruned: string[] = []
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
pruneStaleTasksAndNotifications({
|
|
|
|
|
tasks,
|
|
|
|
|
notifications: new Map<string, BackgroundTask[]>(),
|
|
|
|
|
onTaskPruned: (taskId) => pruned.push(taskId),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(pruned).toEqual([])
|
2026-03-11 21:52:48 +09:00
|
|
|
expect(Array.from(tasks.keys())).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("should keep terminal tasks with pending notifications until notification cleanup", () => {
|
|
|
|
|
//#given
|
|
|
|
|
const task = createTerminalTask()
|
|
|
|
|
const tasks = new Map<string, BackgroundTask>([[task.id, task]])
|
|
|
|
|
const notifications = new Map<string, BackgroundTask[]>([[task.parentSessionID, [task]]])
|
|
|
|
|
const pruned: string[] = []
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
pruneStaleTasksAndNotifications({
|
|
|
|
|
tasks,
|
|
|
|
|
notifications,
|
|
|
|
|
onTaskPruned: (taskId) => pruned.push(taskId),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(pruned).toEqual([])
|
|
|
|
|
expect(tasks.has(task.id)).toBe(true)
|
|
|
|
|
expect(notifications.has(task.parentSessionID)).toBe(false)
|
2026-03-11 18:20:13 +09:00
|
|
|
})
|
2026-02-14 14:56:51 +09:00
|
|
|
})
|