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 //#then
expect(task.status).toBe("cancelled") expect(task.status).toBe("cancelled")
expect(task.error).toContain("no activity") expect(task.error).toContain("no activity")
expect(task.error).toContain("messageStalenessTimeoutMs")
}) })
it("should await abort before resolving for no-progress stale interruption", async () => { it("should await abort before resolving for no-progress stale interruption", async () => {
@@ -403,7 +404,7 @@ describe("checkAndInterruptStaleTasks", () => {
progress: undefined, progress: undefined,
}) })
//#when session is running //#when - session is running
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
@@ -419,7 +420,7 @@ describe("checkAndInterruptStaleTasks", () => {
}) })
it("should NOT cancel healthy task on first missing status poll", async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { progress: {
@@ -445,7 +446,7 @@ describe("checkAndInterruptStaleTasks", () => {
}) })
it("should NOT cancel task when session.get confirms the session still exists", async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { progress: {
@@ -472,7 +473,7 @@ describe("checkAndInterruptStaleTasks", () => {
}) })
it("should NOT cancel task when session.get returns a transient error response", async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { progress: {
@@ -504,7 +505,7 @@ describe("checkAndInterruptStaleTasks", () => {
}) })
it("should use session-gone timeout when session is missing from status map (with progress)", async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { progress: {
@@ -516,7 +517,7 @@ describe("checkAndInterruptStaleTasks", () => {
mockClient.session.get.mockRejectedValue(new Error("missing")) mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when empty sessionStatuses (session gone), sessionGoneTimeoutMs = 60s //#when - empty sessionStatuses (session gone), sessionGoneTimeoutMs = 60s
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
@@ -526,7 +527,7 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: {}, 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.status).toBe("cancelled")
expect(task.error).toContain("session gone from status registry") 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 () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 120_000), startedAt: new Date(Date.now() - 120_000),
progress: undefined, progress: undefined,
@@ -580,7 +581,7 @@ describe("checkAndInterruptStaleTasks", () => {
mockClient.session.get.mockRejectedValue(new Error("missing")) mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when session gone, sessionGoneTimeoutMs = 60s //#when - session gone, sessionGoneTimeoutMs = 60s
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
@@ -590,13 +591,13 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: {}, 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.status).toBe("cancelled")
expect(task.error).toContain("session gone from status registry") 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 () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { progress: {
@@ -608,7 +609,7 @@ describe("checkAndInterruptStaleTasks", () => {
mockClient.session.get.mockRejectedValue(new Error("missing")) 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({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
@@ -618,12 +619,12 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: { "ses-1": { type: "idle" } }, 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") expect(task.status).toBe("running")
}) })
it("should use default session-gone timeout when not configured", async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { progress: {
@@ -635,7 +636,7 @@ describe("checkAndInterruptStaleTasks", () => {
mockClient.session.get.mockRejectedValue(new Error("missing")) mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when no config (default sessionGoneTimeoutMs = 60_000) //#when - no config (default sessionGoneTimeoutMs = 60_000)
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
@@ -645,13 +646,13 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: {}, 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.status).toBe("cancelled")
expect(task.error).toContain("session gone from status registry") expect(task.error).toContain("session gone from status registry")
}) })
it("should interrupt task when busy session exceeds stale timeout", async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { 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({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
@@ -676,7 +677,7 @@ describe("checkAndInterruptStaleTasks", () => {
}) })
it("should interrupt task when retry session exceeds stale timeout", async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { 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({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
@@ -701,13 +702,13 @@ describe("checkAndInterruptStaleTasks", () => {
}) })
it("should interrupt busy session with no progress after message staleness timeout", async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000), startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined, progress: undefined,
}) })
//#when session is busy //#when - session is busy
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, 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 () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { progress: {
@@ -780,7 +781,7 @@ describe("checkAndInterruptStaleTasks", () => {
}, },
}) })
//#when session status is "interrupted" (terminal) //#when - session status is "interrupted" (terminal)
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
@@ -790,13 +791,13 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: { "ses-1": { type: "interrupted" } }, 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.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout") expect(task.error).toContain("Stale timeout")
}) })
it('should NOT protect task when session has unknown status type', async () => { 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({ const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000), startedAt: new Date(Date.now() - 300_000),
progress: { progress: {
@@ -805,7 +806,7 @@ describe("checkAndInterruptStaleTasks", () => {
}, },
}) })
//#when session has unknown status type //#when - session has unknown status type
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
client: mockClient as never, client: mockClient as never,
@@ -815,7 +816,7 @@ describe("checkAndInterruptStaleTasks", () => {
sessionStatuses: { "ses-1": { type: "some-weird-status" } }, 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.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout") 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 staleMinutes = Math.round(runtime / 60000)
const reason = sessionGone ? "session gone from status registry" : "no activity" const reason = sessionGone ? "session gone from status registry" : "no activity"
task.status = "cancelled" 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() task.completedAt = new Date()
if (task.concurrencyKey) { if (task.concurrencyKey) {