From f5740d68c7bf2141949be6ae210ee358dbbaf7ee Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 01:19:52 +0900 Subject: [PATCH] fix(background-agent): bound session abort waits Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../background-agent/abort-with-timeout.ts | 35 +++++++++++++++++++ .../fallback-retry-handler.ts | 3 +- src/features/background-agent/manager.test.ts | 4 +-- src/features/background-agent/manager.ts | 9 ++--- src/features/background-agent/task-poller.ts | 5 +-- 5 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 src/features/background-agent/abort-with-timeout.ts diff --git a/src/features/background-agent/abort-with-timeout.ts b/src/features/background-agent/abort-with-timeout.ts new file mode 100644 index 000000000..49f1170f2 --- /dev/null +++ b/src/features/background-agent/abort-with-timeout.ts @@ -0,0 +1,35 @@ +import { log } from "../../shared" +import type { OpencodeClient } from "./opencode-client" + +export async function abortWithTimeout( + client: OpencodeClient, + sessionID: string, + timeoutMs = 10_000, +): Promise { + let timeoutHandle: ReturnType | undefined + + try { + const result = await Promise.race([ + client.session.abort({ path: { id: sessionID } }).then(() => "aborted" as const), + new Promise<"timed_out">((resolve) => { + timeoutHandle = setTimeout(() => { + resolve("timed_out") + }, timeoutMs) + }), + ]) + + if (result === "timed_out") { + log("[background-agent] Session abort timed out; continuing cleanup:", { + sessionID, + timeoutMs, + }) + return false + } + + return true + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle) + } + } +} diff --git a/src/features/background-agent/fallback-retry-handler.ts b/src/features/background-agent/fallback-retry-handler.ts index f169fa4eb..58549cc98 100644 --- a/src/features/background-agent/fallback-retry-handler.ts +++ b/src/features/background-agent/fallback-retry-handler.ts @@ -10,6 +10,7 @@ import { selectFallbackProvider, } from "../../shared/model-error-classifier" import { transformModelForProvider } from "../../shared/provider-model-id-transform" +import { abortWithTimeout } from "./abort-with-timeout" export async function tryFallbackRetry(args: { task: BackgroundTask @@ -123,7 +124,7 @@ export async function tryFallbackRetry(args: { } if (previousSessionID) { - await client.session.abort({ path: { id: previousSessionID } }).catch(() => {}) + await abortWithTimeout(client, previousSessionID).catch(() => {}) } queue.push({ task, input: retryInput }) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index db5635d33..35766f368 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -238,7 +238,7 @@ function stubNotifyParentSession(manager: BackgroundManager): void { } async function flushBackgroundNotifications(): Promise { - for (let i = 0; i < 6; i++) { + for (let i = 0; i < 12; i++) { await Promise.resolve() } } @@ -2570,7 +2570,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { abortCalled, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 100)), ]) - await Promise.resolve() + await flushBackgroundNotifications() // then const updatedTask = manager.getTask(task.id) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index dc4d23d6b..2dc01e959 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -57,6 +57,7 @@ import { join } from "node:path" import { pruneStaleTasksAndNotifications } from "./task-poller" import { checkAndInterruptStaleTasks } from "./task-poller" import { removeTaskToastTracking } from "./remove-task-toast-tracking" +import { abortWithTimeout } from "./abort-with-timeout" import { MIN_SESSION_GONE_POLLS, verifySessionExists as verifySessionStillExists, @@ -193,9 +194,7 @@ export class BackgroundManager { private async abortSessionWithLogging(sessionID: string, reason: string): Promise { try { - await this.client.session.abort({ - path: { id: sessionID }, - }) + await abortWithTimeout(this.client, sessionID) } catch (error) { log(`[background-agent] Failed to abort session during ${reason}:`, { sessionID, @@ -1985,9 +1984,7 @@ export class BackgroundManager { if (task.status === "running" && task.sessionID) { abortRequests.push({ sessionID: task.sessionID, - promise: this.client.session.abort({ - path: { id: task.sessionID }, - }), + promise: abortWithTimeout(this.client, task.sessionID), }) } } diff --git a/src/features/background-agent/task-poller.ts b/src/features/background-agent/task-poller.ts index 0f2c6e2ce..6fa179bd7 100644 --- a/src/features/background-agent/task-poller.ts +++ b/src/features/background-agent/task-poller.ts @@ -13,6 +13,7 @@ import { TERMINAL_TASK_TTL_MS, TASK_TTL_MS, } from "./constants" +import { abortWithTimeout } from "./abort-with-timeout" import { removeTaskToastTracking } from "./remove-task-toast-tracking" import { MIN_SESSION_GONE_POLLS, verifySessionExists } from "./session-existence" @@ -167,7 +168,7 @@ export async function checkAndInterruptStaleTasks(args: { onTaskInterrupted(task) - abortPromises.push(client.session.abort({ path: { id: sessionID } })) + abortPromises.push(abortWithTimeout(client, sessionID)) log(`[background-agent] Task ${task.id} interrupted: no progress since start`) try { @@ -205,7 +206,7 @@ export async function checkAndInterruptStaleTasks(args: { onTaskInterrupted(task) - abortPromises.push(client.session.abort({ path: { id: sessionID } })) + abortPromises.push(abortWithTimeout(client, sessionID)) log(`[background-agent] Task ${task.id} interrupted: stale timeout`) try {