diff --git a/src/features/background-agent/manager.polling.test.ts b/src/features/background-agent/manager.polling.test.ts index 964d26038..3879f30aa 100644 --- a/src/features/background-agent/manager.polling.test.ts +++ b/src/features/background-agent/manager.polling.test.ts @@ -1,4 +1,6 @@ -import { describe, test, expect } from "bun:test" +/// + +import { describe, test, expect, mock } from "bun:test" import { tmpdir } from "node:os" import type { PluginInput } from "@opencode-ai/plugin" import { BackgroundManager } from "./manager" @@ -78,6 +80,7 @@ function createManagerWithClient(clientOverrides: Record = {}): const client = { session: { status: async () => ({ data: {} }), + get: async () => ({ data: { id: "ses-default" } }), prompt: async () => ({}), promptAsync: async () => ({}), abort: async () => ({}), @@ -97,6 +100,46 @@ function createManagerWithClient(clientOverrides: Record = {}): return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) } +describe("BackgroundManager verifySessionExists", () => { + describe("#given session.get reports a not-found response", () => { + test("#when verifySessionExists runs #then it returns false", async () => { + //#given + const manager = createManagerWithClient({ + get: async () => ({ + error: { message: "Session not found", status: 404 }, + data: undefined, + }), + }) + + //#when + const result = await manager["verifySessionExists"]("ses-missing") + await manager.shutdown() + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given session.get reports a transient transport error", () => { + test("#when verifySessionExists runs #then it returns true", async () => { + //#given + const manager = createManagerWithClient({ + get: async () => ({ + error: { message: "Network timeout", status: 500 }, + data: undefined, + }), + }) + + //#when + const result = await manager["verifySessionExists"]("ses-transient") + await manager.shutdown() + + //#then + expect(result).toBe(true) + }) + }) +}) + 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 () => { @@ -114,6 +157,31 @@ describe("BackgroundManager pollRunningTasks", () => { expect(task.status).toBe("completed") expect(task.completedAt).toBeDefined() }) + + test("#when the first missing-status poll has no output #then it does not fail the task yet", async () => { + //#given + const getSession = mock(async () => ({ + error: { message: "Session not found", status: 404 }, + data: undefined, + })) + const manager = createManagerWithClient({ + get: getSession, + messages: async () => ({ data: [] }), + }) + const task = createRunningTask("ses-first-miss") + injectTask(manager, task) + + //#when + const poll = manager["pollRunningTasks"] + await poll.call(manager) + await manager.shutdown() + + //#then + expect(task.status).toBe("running") + expect(task.error).toBeUndefined() + expect(task.consecutiveMissedPolls).toBe(1) + expect(getSession).not.toHaveBeenCalled() + }) }) describe("#given a running task whose session status is idle", () => { @@ -191,4 +259,4 @@ describe("BackgroundManager pollRunningTasks", () => { expect(task.completedAt).toBeDefined() }) }) -}) \ No newline at end of file +}) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 7050025ba..269886af2 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -3556,6 +3556,10 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => { session: { prompt: async () => ({}), promptAsync: async () => ({}), + get: async () => ({ + error: { message: "Session not found", status: 404 }, + data: undefined, + }), abort: async () => ({}), }, } diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index d35428441..790d92de0 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -53,6 +53,10 @@ import { join } from "node:path" import { pruneStaleTasksAndNotifications } from "./task-poller" import { checkAndInterruptStaleTasks } from "./task-poller" import { removeTaskToastTracking } from "./remove-task-toast-tracking" +import { + MIN_SESSION_GONE_POLLS, + verifySessionExists as verifySessionStillExists, +} from "./session-existence" import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier" import { detectRepetitiveToolUse, @@ -1819,12 +1823,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea } private async verifySessionExists(sessionID: string): Promise { - try { - const result = await this.client.session.get({ path: { id: sessionID } }) - return !!result.data - } catch { - return false - } + return verifySessionStillExists(this.client, sessionID) } private async failCrashedTask(task: BackgroundTask, errorMessage: string): Promise { @@ -1927,18 +1926,22 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea // Session is idle or no longer in status response (completed/disappeared) const sessionGoneFromStatus = !sessionStatus + const sessionGoneThresholdReached = sessionGoneFromStatus + && (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS const completionSource = sessionStatus?.type === "idle" ? "polling (idle status)" : "polling (session gone from status)" const hasValidOutput = await this.validateSessionHasOutput(sessionID) if (!hasValidOutput) { - if (sessionGoneFromStatus) { + if (sessionGoneThresholdReached) { const sessionExists = await this.verifySessionExists(sessionID) if (!sessionExists) { log("[background-agent] Session no longer exists (crashed), marking task as error:", task.id) await this.failCrashedTask(task, "Subagent session no longer exists (process likely crashed). The session disappeared without producing any output.") continue } + + task.consecutiveMissedPolls = 0 } log("[background-agent] Polling idle/gone but no valid output yet, waiting:", task.id) continue diff --git a/src/features/background-agent/session-existence.ts b/src/features/background-agent/session-existence.ts new file mode 100644 index 000000000..6ea520252 --- /dev/null +++ b/src/features/background-agent/session-existence.ts @@ -0,0 +1,50 @@ +import type { OpencodeClient } from "./opencode-client" + +export const MIN_SESSION_GONE_POLLS = 3 + +function extractErrorMessage(error: unknown): string | undefined { + if (typeof error === "string") { + return error + } + + if (typeof error !== "object" || error === null || !("message" in error)) { + return undefined + } + + return typeof error.message === "string" ? error.message : undefined +} + +function extractErrorStatus(error: unknown): number | undefined { + if (typeof error !== "object" || error === null || !("status" in error)) { + return undefined + } + + return typeof error.status === "number" ? error.status : undefined +} + +function isSessionNotFoundError(error: unknown): boolean { + if (extractErrorStatus(error) === 404) { + return true + } + + const message = extractErrorMessage(error)?.toLowerCase() + if (!message) { + return false + } + + return message.includes("not found") || message.includes("missing") +} + +export async function verifySessionExists(client: OpencodeClient, sessionID: string): Promise { + try { + const response = await client.session.get({ path: { id: sessionID } }) + + if (response.error !== undefined && response.error !== null) { + return !isSessionNotFoundError(response.error) + } + + return response.data != null + } catch (error) { + return !isSessionNotFoundError(error) + } +} diff --git a/src/features/background-agent/task-poller.test.ts b/src/features/background-agent/task-poller.test.ts index cd3d8a9cf..0343f99c0 100644 --- a/src/features/background-agent/task-poller.test.ts +++ b/src/features/background-agent/task-poller.test.ts @@ -347,6 +347,38 @@ describe("checkAndInterruptStaleTasks", () => { 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, + 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 use session-gone timeout when session is missing from status map (with progress)", async () => { //#given — lastUpdate 2min ago, session completely gone from status const task = createRunningTask({ diff --git a/src/features/background-agent/task-poller.ts b/src/features/background-agent/task-poller.ts index 803b0f51a..1b32a55f4 100644 --- a/src/features/background-agent/task-poller.ts +++ b/src/features/background-agent/task-poller.ts @@ -14,10 +14,9 @@ import { TASK_TTL_MS, } from "./constants" import { removeTaskToastTracking } from "./remove-task-toast-tracking" +import { MIN_SESSION_GONE_POLLS, verifySessionExists } from "./session-existence" import { isActiveSessionStatus } from "./session-status-classifier" - -const MIN_SESSION_GONE_POLLS = 3 const TERMINAL_TASK_STATUSES = new Set([ "completed", "error", @@ -99,15 +98,6 @@ export function pruneStaleTasksAndNotifications(args: { export type SessionStatusMap = Record -async function verifySessionExists(client: OpencodeClient, sessionID: string): Promise { - try { - const result = await client.session.get({ path: { id: sessionID } }) - return !!result.data - } catch { - return false - } -} - export async function checkAndInterruptStaleTasks(args: { tasks: Iterable client: OpencodeClient