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 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
@@ -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
@@ -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
+15 -3
View File
@@ -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: {
@@ -3027,10 +3039,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),
},
}
@@ -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
@@ -117,13 +129,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,
+5 -5
View File
@@ -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)