fix(background-agent): correct stall timeout guidance

This commit is contained in:
Disaster-Terminator
2026-04-22 18:28:21 +08:00
committed by YeonGyu-Kim
parent 189af23e96
commit b6caa5d3e0
2 changed files with 30 additions and 29 deletions
@@ -177,6 +177,7 @@ describe("checkAndInterruptStaleTasks", () => {
//#then
expect(task.status).toBe("cancelled")
expect(task.error).toContain("no activity")
expect(task.error).toContain("messageStalenessTimeoutMs")
})
it("should await abort before resolving for no-progress stale interruption", async () => {
@@ -403,7 +404,7 @@ describe("checkAndInterruptStaleTasks", () => {
progress: undefined,
})
//#when session is running
//#when - session is running
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -419,7 +420,7 @@ describe("checkAndInterruptStaleTasks", () => {
})
it("should NOT cancel healthy task on first missing status poll", async () => {
//#given one missing poll should not be enough to declare the session gone
//#given - one missing poll should not be enough to declare the session gone
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -445,7 +446,7 @@ describe("checkAndInterruptStaleTasks", () => {
})
it("should NOT cancel task when session.get confirms the session still exists", async () => {
//#given repeated missing polls but direct lookup still succeeds
//#given - repeated missing polls but direct lookup still succeeds
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -472,7 +473,7 @@ describe("checkAndInterruptStaleTasks", () => {
})
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
//#given - repeated missing polls but lookup failed with a retryable transport error
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -504,7 +505,7 @@ describe("checkAndInterruptStaleTasks", () => {
})
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
//#given - lastUpdate 2min ago, session completely gone from status
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -516,7 +517,7 @@ describe("checkAndInterruptStaleTasks", () => {
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when empty sessionStatuses (session gone), sessionGoneTimeoutMs = 60s
//#when - empty sessionStatuses (session gone), sessionGoneTimeoutMs = 60s
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -526,7 +527,7 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: {},
})
//#then cancelled because session gone timeout (60s) < timeSinceLastUpdate (120s)
//#then - cancelled because session gone timeout (60s) < timeSinceLastUpdate (120s)
expect(task.status).toBe("cancelled")
expect(task.error).toContain("session gone from status registry")
})
@@ -571,7 +572,7 @@ describe("checkAndInterruptStaleTasks", () => {
})
it("should use session-gone timeout when session is missing from status map (no progress)", async () => {
//#given task started 2min ago, no progress, session completely gone
//#given - task started 2min ago, no progress, session completely gone
const task = createRunningTask({
startedAt: new Date(Date.now() - 120_000),
progress: undefined,
@@ -580,7 +581,7 @@ describe("checkAndInterruptStaleTasks", () => {
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when session gone, sessionGoneTimeoutMs = 60s
//#when - session gone, sessionGoneTimeoutMs = 60s
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -590,13 +591,13 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: {},
})
//#then cancelled because session gone timeout (60s) < runtime (120s)
//#then - cancelled because session gone timeout (60s) < runtime (120s)
expect(task.status).toBe("cancelled")
expect(task.error).toContain("session gone from status registry")
})
it("should NOT use session-gone timeout when session is idle (present in status map)", async () => {
//#given lastUpdate 2min ago, session is idle (present in status but not active)
//#given - lastUpdate 2min ago, session is idle (present in status but not active)
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -608,7 +609,7 @@ describe("checkAndInterruptStaleTasks", () => {
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when session is idle (present in map), staleTimeoutMs = 180s
//#when - session is idle (present in map), staleTimeoutMs = 180s
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -618,12 +619,12 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: { "ses-1": { type: "idle" } },
})
//#then still running because normal staleTimeout (180s) > timeSinceLastUpdate (120s)
//#then - still running because normal staleTimeout (180s) > timeSinceLastUpdate (120s)
expect(task.status).toBe("running")
})
it("should use default session-gone timeout when not configured", async () => {
//#given lastUpdate 2min ago, session gone, no sessionGoneTimeoutMs config
//#given - lastUpdate 2min ago, session gone, no sessionGoneTimeoutMs config
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -635,7 +636,7 @@ describe("checkAndInterruptStaleTasks", () => {
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when no config (default sessionGoneTimeoutMs = 60_000)
//#when - no config (default sessionGoneTimeoutMs = 60_000)
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -645,13 +646,13 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: {},
})
//#then cancelled because default session gone timeout (60s) < timeSinceLastUpdate (120s)
//#then - cancelled because default session gone timeout (60s) < timeSinceLastUpdate (120s)
expect(task.status).toBe("cancelled")
expect(task.error).toContain("session gone from status registry")
})
it("should interrupt task when busy session exceeds stale timeout", async () => {
//#given lastUpdate is 5min old and session is still "busy"
//#given - lastUpdate is 5min old and session is still "busy"
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -660,7 +661,7 @@ describe("checkAndInterruptStaleTasks", () => {
},
})
//#when session status is "busy" (not "running" OpenCode uses "busy" for active LLM processing)
//#when - session status is "busy" (not "running" - OpenCode uses "busy" for active LLM processing)
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -676,7 +677,7 @@ describe("checkAndInterruptStaleTasks", () => {
})
it("should interrupt task when retry session exceeds stale timeout", async () => {
//#given lastUpdate is 5min old but session is retrying
//#given - lastUpdate is 5min old but session is retrying
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -685,7 +686,7 @@ describe("checkAndInterruptStaleTasks", () => {
},
})
//#when session status is "retry" (OpenCode retries on transient API errors)
//#when - session status is "retry" (OpenCode retries on transient API errors)
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -701,13 +702,13 @@ describe("checkAndInterruptStaleTasks", () => {
})
it("should interrupt busy session with no progress after message staleness timeout", async () => {
//#given no progress at all, session is still "busy"
//#given - no progress at all, session is still "busy"
const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
})
//#when session is busy
//#when - session is busy
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -771,7 +772,7 @@ describe("checkAndInterruptStaleTasks", () => {
})
it('should NOT protect task when session has terminal non-idle status like "interrupted"', async () => {
//#given lastUpdate is 5min old, session is "interrupted" (terminal, not active)
//#given - lastUpdate is 5min old, session is "interrupted" (terminal, not active)
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -780,7 +781,7 @@ describe("checkAndInterruptStaleTasks", () => {
},
})
//#when session status is "interrupted" (terminal)
//#when - session status is "interrupted" (terminal)
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -790,13 +791,13 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: { "ses-1": { type: "interrupted" } },
})
//#then terminal statuses should not protect from stale timeout
//#then - terminal statuses should not protect from stale timeout
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
it('should NOT protect task when session has unknown status type', async () => {
//#given lastUpdate is 5min old, session has an unknown status
//#given - lastUpdate is 5min old, session has an unknown status
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
@@ -805,7 +806,7 @@ describe("checkAndInterruptStaleTasks", () => {
},
})
//#when session has unknown status type
//#when - session has unknown status type
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
@@ -815,7 +816,7 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: { "ses-1": { type: "some-weird-status" } },
})
//#then unknown statuses should not protect from stale timeout
//#then - unknown statuses should not protect from stale timeout
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
+1 -1
View File
@@ -172,7 +172,7 @@ export async function checkAndInterruptStaleTasks(args: {
const staleMinutes = Math.round(runtime / 60000)
const reason = sessionGone ? "session gone from status registry" : "no activity"
task.status = "cancelled"
task.error = `Stale timeout (${reason} 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.${sessionGone ? "sessionGoneTimeoutMs" : "staleTimeoutMs"}' in .opencode/${CONFIG_BASENAME}.json.`
task.error = `Stale timeout (${reason} 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.${sessionGone ? "sessionGoneTimeoutMs" : "messageStalenessTimeoutMs"}' in .opencode/${CONFIG_BASENAME}.json.`
task.completedAt = new Date()
if (task.concurrencyKey) {