952bd5338d
Previously, pollRunningTasks() and checkAndInterruptStaleTasks() treated
any non-"idle" session status as "still running", which caused tasks with
terminal statuses like "interrupted" to be skipped indefinitely — both
for completion detection AND stale timeout. This made the parent session
hang forever waiting for an ALL COMPLETE notification that never came.
Extract isActiveSessionStatus() and isTerminalSessionStatus() that
classify session statuses explicitly. Only known active statuses
("busy", "retry", "running") protect tasks from completion/stale checks.
Known terminal statuses ("interrupted") trigger immediate completion.
Unknown statuses fall through to the standard idle/gone path with output
validation as a conservative default.
Introduced by: a0c93816 (2026-02-14), dc370f7f (2026-03-08)
194 lines
6.2 KiB
TypeScript
194 lines
6.2 KiB
TypeScript
import { describe, test, expect } from "bun:test"
|
|
import { tmpdir } from "node:os"
|
|
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import { BackgroundManager } from "./manager"
|
|
import type { BackgroundTask } from "./types"
|
|
|
|
function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string, { type: string }> }>): BackgroundManager {
|
|
const client = {
|
|
session: {
|
|
status: statusImpl,
|
|
prompt: async () => ({}),
|
|
promptAsync: async () => ({}),
|
|
abort: async () => ({}),
|
|
todo: async () => ({ data: [] }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
|
}
|
|
|
|
describe("BackgroundManager polling overlap", () => {
|
|
test("skips overlapping pollRunningTasks executions", async () => {
|
|
//#given
|
|
let activeCalls = 0
|
|
let maxActiveCalls = 0
|
|
let statusCallCount = 0
|
|
let releaseStatus: (() => void) | undefined
|
|
const statusGate = new Promise<void>((resolve) => {
|
|
releaseStatus = resolve
|
|
})
|
|
|
|
const manager = createManagerWithStatus(async () => {
|
|
statusCallCount += 1
|
|
activeCalls += 1
|
|
maxActiveCalls = Math.max(maxActiveCalls, activeCalls)
|
|
await statusGate
|
|
activeCalls -= 1
|
|
return { data: {} }
|
|
})
|
|
|
|
//#when
|
|
const firstPoll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks()
|
|
await Promise.resolve()
|
|
const secondPoll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks()
|
|
releaseStatus?.()
|
|
await Promise.all([firstPoll, secondPoll])
|
|
manager.shutdown()
|
|
|
|
//#then
|
|
expect(maxActiveCalls).toBe(1)
|
|
expect(statusCallCount).toBe(1)
|
|
})
|
|
})
|
|
|
|
|
|
function createRunningTask(sessionID: string): BackgroundTask {
|
|
return {
|
|
id: `bg_test_${sessionID}`,
|
|
sessionID,
|
|
parentSessionID: "parent-session",
|
|
parentMessageID: "parent-msg",
|
|
description: "test task",
|
|
prompt: "test",
|
|
agent: "explore",
|
|
status: "running",
|
|
startedAt: new Date(),
|
|
progress: { toolCalls: 0, lastUpdate: new Date() },
|
|
}
|
|
}
|
|
|
|
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
|
|
const tasks = (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
|
|
tasks.set(task.id, task)
|
|
}
|
|
|
|
function createManagerWithClient(clientOverrides: Record<string, unknown> = {}): BackgroundManager {
|
|
const client = {
|
|
session: {
|
|
status: async () => ({ data: {} }),
|
|
prompt: async () => ({}),
|
|
promptAsync: async () => ({}),
|
|
abort: async () => ({}),
|
|
todo: async () => ({ data: [] }),
|
|
messages: async () => ({
|
|
data: [{
|
|
info: { role: "assistant", finish: "end_turn", id: "msg-2" },
|
|
parts: [{ type: "text", text: "done" }],
|
|
}, {
|
|
info: { role: "user", id: "msg-1" },
|
|
parts: [{ type: "text", text: "go" }],
|
|
}],
|
|
}),
|
|
...clientOverrides,
|
|
},
|
|
}
|
|
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
|
}
|
|
|
|
describe("BackgroundManager pollRunningTasks", () => {
|
|
describe("#given a running task whose session is no longer in status response", () => {
|
|
test("#when pollRunningTasks runs #then completes the task instead of leaving it running", async () => {
|
|
//#given
|
|
const manager = createManagerWithClient()
|
|
const task = createRunningTask("ses-gone")
|
|
injectTask(manager, task)
|
|
|
|
//#when
|
|
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
|
await poll.call(manager)
|
|
manager.shutdown()
|
|
|
|
//#then
|
|
expect(task.status).toBe("completed")
|
|
expect(task.completedAt).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe("#given a running task whose session status is idle", () => {
|
|
test("#when pollRunningTasks runs #then completes the task", async () => {
|
|
//#given
|
|
const manager = createManagerWithClient({
|
|
status: async () => ({ data: { "ses-idle": { type: "idle" } } }),
|
|
})
|
|
const task = createRunningTask("ses-idle")
|
|
injectTask(manager, task)
|
|
|
|
//#when
|
|
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
|
await poll.call(manager)
|
|
manager.shutdown()
|
|
|
|
//#then
|
|
expect(task.status).toBe("completed")
|
|
})
|
|
})
|
|
|
|
describe("#given a running task whose session status is busy", () => {
|
|
test("#when pollRunningTasks runs #then keeps the task running", async () => {
|
|
//#given
|
|
const manager = createManagerWithClient({
|
|
status: async () => ({ data: { "ses-busy": { type: "busy" } } }),
|
|
})
|
|
const task = createRunningTask("ses-busy")
|
|
injectTask(manager, task)
|
|
|
|
//#when
|
|
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
|
await poll.call(manager)
|
|
manager.shutdown()
|
|
|
|
//#then
|
|
expect(task.status).toBe("running")
|
|
})
|
|
})
|
|
|
|
describe("#given a running task whose session has terminal non-idle status", () => {
|
|
test('#when session status is "interrupted" #then completes the task', async () => {
|
|
//#given
|
|
const manager = createManagerWithClient({
|
|
status: async () => ({ data: { "ses-interrupted": { type: "interrupted" } } }),
|
|
})
|
|
const task = createRunningTask("ses-interrupted")
|
|
injectTask(manager, task)
|
|
|
|
//#when
|
|
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
|
await poll.call(manager)
|
|
manager.shutdown()
|
|
|
|
//#then
|
|
expect(task.status).toBe("completed")
|
|
expect(task.completedAt).toBeDefined()
|
|
})
|
|
|
|
test('#when session status is an unknown type #then completes the task', async () => {
|
|
//#given
|
|
const manager = createManagerWithClient({
|
|
status: async () => ({ data: { "ses-unknown": { type: "some-weird-status" } } }),
|
|
})
|
|
const task = createRunningTask("ses-unknown")
|
|
injectTask(manager, task)
|
|
|
|
//#when
|
|
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
|
|
await poll.call(manager)
|
|
manager.shutdown()
|
|
|
|
//#then
|
|
expect(task.status).toBe("completed")
|
|
expect(task.completedAt).toBeDefined()
|
|
})
|
|
})
|
|
}) |