From fda17dd161d7803c5f6b6c9450d1274c28696e9c Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 23 Mar 2026 20:49:43 +0900 Subject: [PATCH 1/3] fix(background-agent): increase default stale timeouts and improve cancellation messages (fixes #2684) --- src/features/background-agent/constants.ts | 4 ++-- .../default-message-staleness-timeout.test.ts | 4 ++-- .../background-agent/default-stale-timeout.test.ts | 4 ++-- src/features/background-agent/task-poller.ts | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/features/background-agent/constants.ts b/src/features/background-agent/constants.ts index 9df6c2ff2..9c20c0f61 100644 --- a/src/features/background-agent/constants.ts +++ b/src/features/background-agent/constants.ts @@ -4,8 +4,8 @@ import type { BackgroundTask, LaunchInput } from "./types" export const TASK_TTL_MS = 30 * 60 * 1000 export const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000 export const MIN_STABILITY_TIME_MS = 10 * 1000 -export const DEFAULT_STALE_TIMEOUT_MS = 1_200_000 -export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 1_800_000 +export const DEFAULT_STALE_TIMEOUT_MS = 2_700_000 +export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 3_600_000 export const DEFAULT_MAX_TOOL_CALLS = 4000 export const DEFAULT_CIRCUIT_BREAKER_CONSECUTIVE_THRESHOLD = 20 export const DEFAULT_CIRCUIT_BREAKER_ENABLED = true diff --git a/src/features/background-agent/default-message-staleness-timeout.test.ts b/src/features/background-agent/default-message-staleness-timeout.test.ts index b13bf191a..d8b4e6671 100644 --- a/src/features/background-agent/default-message-staleness-timeout.test.ts +++ b/src/features/background-agent/default-message-staleness-timeout.test.ts @@ -21,9 +21,9 @@ function createRunningTask(startedAt: Date): BackgroundTask { } describe("DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS", () => { - test("uses a 30 minute default", () => { + test("uses a 60 minute default", () => { // #given - const expectedTimeout = 30 * 60 * 1000 + const expectedTimeout = 60 * 60 * 1000 // #when const timeout = DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS diff --git a/src/features/background-agent/default-stale-timeout.test.ts b/src/features/background-agent/default-stale-timeout.test.ts index 704a530c9..3f63ca28a 100644 --- a/src/features/background-agent/default-stale-timeout.test.ts +++ b/src/features/background-agent/default-stale-timeout.test.ts @@ -4,9 +4,9 @@ const { describe, expect, test } = require("bun:test") import { DEFAULT_STALE_TIMEOUT_MS } from "./constants" describe("DEFAULT_STALE_TIMEOUT_MS", () => { - test("uses a 20 minute default", () => { + test("uses a 45 minute default", () => { // #given - const expectedTimeout = 20 * 60 * 1000 + const expectedTimeout = 45 * 60 * 1000 // #when const timeout = DEFAULT_STALE_TIMEOUT_MS diff --git a/src/features/background-agent/task-poller.ts b/src/features/background-agent/task-poller.ts index d23a241ae..84ba4c56e 100644 --- a/src/features/background-agent/task-poller.ts +++ b/src/features/background-agent/task-poller.ts @@ -130,7 +130,7 @@ export async function checkAndInterruptStaleTasks(args: { const staleMinutes = Math.round(runtime / 60000) task.status = "cancelled" - task.error = `Stale timeout (no activity for ${staleMinutes}min since start)` + task.error = `Stale timeout (no activity for ${staleMinutes}min since start). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.staleTimeoutMs' in .opencode/oh-my-opencode.json.` task.completedAt = new Date() if (task.concurrencyKey) { @@ -159,10 +159,10 @@ export async function checkAndInterruptStaleTasks(args: { if (timeSinceLastUpdate <= staleTimeoutMs) continue if (task.status !== "running") continue - const staleMinutes = Math.round(timeSinceLastUpdate / 60000) - task.status = "cancelled" - task.error = `Stale timeout (no activity for ${staleMinutes}min)` - task.completedAt = new Date() + const staleMinutes = Math.round(timeSinceLastUpdate / 60000) + task.status = "cancelled" + task.error = `Stale timeout (no activity for ${staleMinutes}min). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.staleTimeoutMs' in .opencode/oh-my-opencode.json.` + task.completedAt = new Date() if (task.concurrencyKey) { concurrencyManager.release(task.concurrencyKey) From 6d7f69625b3c14329d6597c56d391987c543433c Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 23 Mar 2026 21:00:59 +0900 Subject: [PATCH 2/3] fix: update stale timeout test fixtures for new 45/60 min defaults --- src/features/background-agent/manager.test.ts | 4 ++-- src/features/background-agent/task-poller.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index bef56a647..44b4c0859 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -3027,10 +3027,10 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => { prompt: "Test", agent: "test-agent", status: "running", - startedAt: new Date(Date.now() - 25 * 60 * 1000), + startedAt: new Date(Date.now() - 50 * 60 * 1000), progress: { toolCalls: 1, - lastUpdate: new Date(Date.now() - 21 * 60 * 1000), + lastUpdate: new Date(Date.now() - 46 * 60 * 1000), }, } diff --git a/src/features/background-agent/task-poller.test.ts b/src/features/background-agent/task-poller.test.ts index 7895dfe09..a39a51b40 100644 --- a/src/features/background-agent/task-poller.test.ts +++ b/src/features/background-agent/task-poller.test.ts @@ -117,13 +117,13 @@ describe("checkAndInterruptStaleTasks", () => { }) it("should use DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS when messageStalenessTimeoutMs is not configured", async () => { - //#given — task started 35 minutes ago, no config for messageStalenessTimeoutMs + //#given — task started 65 minutes ago, no config for messageStalenessTimeoutMs const task = createRunningTask({ - startedAt: new Date(Date.now() - 35 * 60 * 1000), + startedAt: new Date(Date.now() - 65 * 60 * 1000), progress: undefined, }) - //#when — default is 30 minutes (1_800_000ms) + //#when — default is 60 minutes (3_600_000ms) await checkAndInterruptStaleTasks({ tasks: [task], client: mockClient as never, From 0078b736b97cd3d99b59f3a1740b3de2a8135f6a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 23 Mar 2026 22:17:03 +0900 Subject: [PATCH 3/3] fix: stabilize stale timeout tests with fixed Date.now() Tests 'should use default timeout when config not provided' (manager.test.ts) and 'should use DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS when not configured' (task-poller.test.ts) failed in CI because Date.now() drifted between test setup (when creating timestamps like Date.now() - 46*60*1000) and actual execution inside checkAndInterruptStaleTasks(). On slower CI machines, this drift pushed borderline values across the threshold, causing tasks that should be stale to remain 'running'. Fix: Mock Date.now with spyOn to return a fixed time, ensuring consistent timeout calculations regardless of execution speed. --- src/features/background-agent/manager.test.ts | 14 +++++++++++++- src/features/background-agent/task-poller.test.ts | 14 +++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 44b4c0859..4553cf1cb 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1,5 +1,5 @@ declare const require: (name: string) => any -const { describe, test, expect, beforeEach, afterEach } = require("bun:test") +const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test") import { tmpdir } from "node:os" import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundTask, ResumeInput } from "./types" @@ -2781,6 +2781,18 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { }) describe("BackgroundManager.checkAndInterruptStaleTasks", () => { + const originalDateNow = Date.now + let fixedTime: number + + beforeEach(() => { + fixedTime = Date.now() + spyOn(globalThis.Date, "now").mockReturnValue(fixedTime) + }) + + afterEach(() => { + Date.now = originalDateNow + }) + test("should NOT interrupt task running less than 30 seconds (min runtime guard)", async () => { const client = { session: { diff --git a/src/features/background-agent/task-poller.test.ts b/src/features/background-agent/task-poller.test.ts index a39a51b40..ec3d09a74 100644 --- a/src/features/background-agent/task-poller.test.ts +++ b/src/features/background-agent/task-poller.test.ts @@ -1,5 +1,5 @@ declare const require: (name: string) => any -const { describe, it, expect, mock } = require("bun:test") +const { describe, it, expect, mock, spyOn, beforeEach, afterEach } = require("bun:test") import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications } from "./task-poller" import type { BackgroundTask } from "./types" @@ -29,6 +29,18 @@ describe("checkAndInterruptStaleTasks", () => { ...overrides, } } + const originalDateNow = Date.now + let fixedTime: number + + beforeEach(() => { + fixedTime = Date.now() + spyOn(globalThis.Date, "now").mockReturnValue(fixedTime) + }) + + afterEach(() => { + Date.now = originalDateNow + }) + it("should interrupt tasks with lastUpdate exceeding stale timeout", async () => { //#given