diff --git a/server/src/__tests__/task-interruption-query.test.ts b/server/src/__tests__/task-interruption-query.test.ts index a179045cb39..626c59d52cc 100644 --- a/server/src/__tests__/task-interruption-query.test.ts +++ b/server/src/__tests__/task-interruption-query.test.ts @@ -222,4 +222,24 @@ describe.skipIf(!support.supported)("task interruption latest run query", () => taskId: reassigned!.id, agentAdapter: "process", })); }); + // N-03: ONB-1 on staging waited 16 minutes on an overdue workspace_busy + // retry. Nothing failed, yet the banner offered a Retry that cannot help. + it.each(["workspace_busy", "ai_connection_busy", "ai_connection_pool_wait", "runner_revision_wait"])( + "does not present an overdue %s wait as a failed attempt", async reason => { + const companyId = randomUUID(), agentId = randomUUID(), parentRunId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Waiting task", issuePrefix: `W${companyId.slice(0, 7)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Waiting agent", adapterType: "process" }); + const [issue] = await db.insert(issues).values({ companyId, title: "Paperclip onboarding", + status: "in_progress", assigneeAgentId: agentId }).returning(); + const now = Date.now(); + await db.insert(heartbeatRuns).values({ id: parentRunId, companyId, agentId, issueId: issue!.id, scopeKind: "issue", + status: "cancelled", errorCode: reason, createdAt: new Date(now - 10 * 60_000), finishedAt: new Date(now - 9 * 60_000) }); + await db.insert(heartbeatRuns).values({ companyId, agentId, issueId: issue!.id, scopeKind: "issue", + status: "scheduled_retry", retryOfRunId: parentRunId, scheduledRetryReason: reason, scheduledRetryAttempt: 2, + scheduledRetryAt: new Date(now - 5 * 60_000), createdAt: new Date(now - 9 * 60_000) }); + captureTaskInterruptionFailure.mockClear(); + const presentation = (await taskInterruptionPresentations(db, companyId, [issue!])).get(issue!.id); + expect(presentation).toMatchObject({ responsibility: "system", state: "working", canRetry: false }); + expect(captureTaskInterruptionFailure).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/services/task-interruption-presentation.test.ts b/server/src/services/task-interruption-presentation.test.ts index 9be38f14077..749e952a807 100644 --- a/server/src/services/task-interruption-presentation.test.ts +++ b/server/src/services/task-interruption-presentation.test.ts @@ -259,3 +259,24 @@ describe("saved-work recovery step", () => { paused: false, approval: false }, now)).not.toHaveProperty("recoveryStep"); }); }); + +describe("scheduled resource wait presentation", () => { + const now = Date.parse("2026-01-01T12:00:00Z"); + const waiting = { ...run, status: "scheduled_retry", errorCode: null, resultJson: null, + scheduledRetryReason: "workspace_busy", scheduledRetryAt: new Date(now - 60_000) }; + + it("keeps an overdue workspace wait out of the failed-attempt UI", () => { + expect(projectTaskInterruption(issue, { run: waiting, agent, paused: false, approval: false }, now)) + .toMatchObject({ state: "working", canRetry: false }); + }); + + it("keeps failure retries, user gates and failed Retry preparation authoritative", () => { + expect(projectTaskInterruption(issue, { run: { ...waiting, scheduledRetryReason: "transient_failure" }, + agent, paused: false, approval: false }, now)).toMatchObject({ state: "failed", canRetry: true }); + expect(projectTaskInterruption(issue, { run: waiting, agent, paused: true, approval: false }, now)) + .toMatchObject({ state: "waiting_for_user", userAction: "resume" }); + expect(projectTaskInterruption(issue, { run: waiting, agent, paused: false, approval: false, + retryWait: { id: "retry", sourceRunId: run.id, requestedAt: new Date(now), preparationStatus: "failed" } }, now)) + .toMatchObject({ state: "failed", canRetry: true }); + }); +}); diff --git a/server/src/services/task-interruption-presentation.ts b/server/src/services/task-interruption-presentation.ts index 761102fa847..ea8f1402b25 100644 --- a/server/src/services/task-interruption-presentation.ts +++ b/server/src/services/task-interruption-presentation.ts @@ -17,11 +17,20 @@ import { logger } from "../middleware/logger.js"; import { canonicalExecutionUuid } from "./execution-identity.js"; import { chatActionDeadline } from "./chat-action-work.js"; import { isQueuedDependencyWait } from "./run-cancellation.js"; +import { AI_CONNECTION_BUSY_RETRY_REASON, AI_CONNECTION_POOL_WAIT_RETRY_REASON, RUNNER_REVISION_WAIT_RETRY_REASON, + WORKSPACE_BUSY_RETRY_REASON } from "../modules/run-dispatch/index.js"; + +// These scheduled runs wait for a shared resource; none records a failed +// attempt. Their dispatcher re-checks the resource, so the run can stay +// scheduled past its retry time. Retry cannot free the resource either. +const resourceWaitRetryReasons = new Set([WORKSPACE_BUSY_RETRY_REASON, AI_CONNECTION_BUSY_RETRY_REASON, + AI_CONNECTION_POOL_WAIT_RETRY_REASON, RUNNER_REVISION_WAIT_RETRY_REASON]); type ProjectionIssue = Pick; type ProjectionRun = Pick & - Partial>; + Partial>; type ProjectionWait = { id: string; requestedAt: Date; sourceRunId?: string | null; executionWaitReason?: string | null; preparationStatus?: string | null; preparationRetryAt?: number | string | null }; type ProjectionState = { @@ -132,6 +141,8 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio return { ...base, state: "retry_scheduled", nextRetryAt: new Date(deadline).toISOString() }; if (!pending) return { ...base, state: "failed", canRetry: retryWait.preparationStatus !== "cancelled" }; } + if (run.status === "scheduled_retry" && resourceWaitRetryReasons.has(run.scheduledRetryReason ?? "") && !system) + return { ...base, state: "working" }; const retryAt = system?.nextAttemptAt ?? run.scheduledRetryAt?.getTime(); if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled", nextRetryAt: new Date(retryAt).toISOString(), canRetry: !preparationPending, @@ -184,6 +195,7 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i and ${heartbeatRuns.error} = 'Project is outside this low-trust boundary.' or ${heartbeatRuns.errorCode} = 'workspace_validation_failed' then ${heartbeatRuns.error} else null end`, errorCode: heartbeatRuns.errorCode, livenessState: heartbeatRuns.livenessState, scheduledRetryAt: heartbeatRuns.scheduledRetryAt, + scheduledRetryReason: heartbeatRuns.scheduledRetryReason, resultJson: sql>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete', 'stopReason', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'stopReason'`, 80)}, 'timeoutSource', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'timeoutSource'`, 80)},