Merge pull request #2776 from code-yeongyu/fix/background-agent-timeout-defaults

fix: stabilize background-agent stale timeout tests (Date.now race condition)
This commit is contained in:
YeonGyu-Kim
2026-03-24 03:29:35 +09:00
committed by GitHub
6 changed files with 42 additions and 18 deletions
+2 -2
View File
@@ -4,8 +4,8 @@ import type { BackgroundTask, LaunchInput } from "./types"
export const TASK_TTL_MS = 30 * 60 * 1000 export const TASK_TTL_MS = 30 * 60 * 1000
export const TERMINAL_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 MIN_STABILITY_TIME_MS = 10 * 1000
export const DEFAULT_STALE_TIMEOUT_MS = 1_200_000 export const DEFAULT_STALE_TIMEOUT_MS = 2_700_000
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 1_800_000 export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 3_600_000
export const DEFAULT_MAX_TOOL_CALLS = 4000 export const DEFAULT_MAX_TOOL_CALLS = 4000
export const DEFAULT_CIRCUIT_BREAKER_CONSECUTIVE_THRESHOLD = 20 export const DEFAULT_CIRCUIT_BREAKER_CONSECUTIVE_THRESHOLD = 20
export const DEFAULT_CIRCUIT_BREAKER_ENABLED = true export const DEFAULT_CIRCUIT_BREAKER_ENABLED = true
@@ -21,9 +21,9 @@ function createRunningTask(startedAt: Date): BackgroundTask {
} }
describe("DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS", () => { describe("DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS", () => {
test("uses a 30 minute default", () => { test("uses a 60 minute default", () => {
// #given // #given
const expectedTimeout = 30 * 60 * 1000 const expectedTimeout = 60 * 60 * 1000
// #when // #when
const timeout = DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS const timeout = DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
@@ -4,9 +4,9 @@ const { describe, expect, test } = require("bun:test")
import { DEFAULT_STALE_TIMEOUT_MS } from "./constants" import { DEFAULT_STALE_TIMEOUT_MS } from "./constants"
describe("DEFAULT_STALE_TIMEOUT_MS", () => { describe("DEFAULT_STALE_TIMEOUT_MS", () => {
test("uses a 20 minute default", () => { test("uses a 45 minute default", () => {
// #given // #given
const expectedTimeout = 20 * 60 * 1000 const expectedTimeout = 45 * 60 * 1000
// #when // #when
const timeout = DEFAULT_STALE_TIMEOUT_MS const timeout = DEFAULT_STALE_TIMEOUT_MS
+15 -3
View File
@@ -1,5 +1,5 @@
declare const require: (name: string) => any 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 { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask, ResumeInput } from "./types" import type { BackgroundTask, ResumeInput } from "./types"
@@ -2781,6 +2781,18 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}) })
describe("BackgroundManager.checkAndInterruptStaleTasks", () => { 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 () => { test("should NOT interrupt task running less than 30 seconds (min runtime guard)", async () => {
const client = { const client = {
session: { session: {
@@ -3027,10 +3039,10 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
prompt: "Test", prompt: "Test",
agent: "test-agent", agent: "test-agent",
status: "running", status: "running",
startedAt: new Date(Date.now() - 25 * 60 * 1000), startedAt: new Date(Date.now() - 50 * 60 * 1000),
progress: { progress: {
toolCalls: 1, toolCalls: 1,
lastUpdate: new Date(Date.now() - 21 * 60 * 1000), lastUpdate: new Date(Date.now() - 46 * 60 * 1000),
}, },
} }
@@ -1,5 +1,5 @@
declare const require: (name: string) => any 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 { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications } from "./task-poller"
import type { BackgroundTask } from "./types" import type { BackgroundTask } from "./types"
@@ -29,6 +29,18 @@ describe("checkAndInterruptStaleTasks", () => {
...overrides, ...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 () => { it("should interrupt tasks with lastUpdate exceeding stale timeout", async () => {
//#given //#given
@@ -117,13 +129,13 @@ describe("checkAndInterruptStaleTasks", () => {
}) })
it("should use DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS when messageStalenessTimeoutMs is not configured", async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 35 * 60 * 1000), startedAt: new Date(Date.now() - 65 * 60 * 1000),
progress: undefined, progress: undefined,
}) })
//#when — default is 30 minutes (1_800_000ms) //#when — default is 60 minutes (3_600_000ms)
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
+5 -5
View File
@@ -130,7 +130,7 @@ export async function checkAndInterruptStaleTasks(args: {
const staleMinutes = Math.round(runtime / 60000) const staleMinutes = Math.round(runtime / 60000)
task.status = "cancelled" 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() task.completedAt = new Date()
if (task.concurrencyKey) { if (task.concurrencyKey) {
@@ -159,10 +159,10 @@ export async function checkAndInterruptStaleTasks(args: {
if (timeSinceLastUpdate <= staleTimeoutMs) continue if (timeSinceLastUpdate <= staleTimeoutMs) continue
if (task.status !== "running") continue if (task.status !== "running") continue
const staleMinutes = Math.round(timeSinceLastUpdate / 60000) const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
task.status = "cancelled" task.status = "cancelled"
task.error = `Stale timeout (no activity for ${staleMinutes}min)` 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() task.completedAt = new Date()
if (task.concurrencyKey) { if (task.concurrencyKey) {
concurrencyManager.release(task.concurrencyKey) concurrencyManager.release(task.concurrencyKey)