diff --git a/src/features/background-agent/task-poller.test.ts b/src/features/background-agent/task-poller.test.ts index 3132de21b..b08f78c59 100644 --- a/src/features/background-agent/task-poller.test.ts +++ b/src/features/background-agent/task-poller.test.ts @@ -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") }) diff --git a/src/features/background-agent/task-poller.ts b/src/features/background-agent/task-poller.ts index 408db3b83..9190e4994 100644 --- a/src/features/background-agent/task-poller.ts +++ b/src/features/background-agent/task-poller.ts @@ -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) {