Files
PaperClipAI/server/src/services/task-interruption-presentation.test.ts
T
DottaandClaude Opus 5.5 9ceaec6f97 fix(issues): stop showing resource waits as failed attempts (#19369)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The task page shows one interruption banner. The server builds it
from the task's latest run.
> - An auto-retry that waits for a busy workspace, AI connection or
runner update is not a failure. The dispatcher re-checks the resource,
so the run can stay `scheduled_retry` after its retry time.
> - The projection did not know about these waits. After the retry time
passed, it fell through to "Couldn't finish this attempt" with a Retry
button.
> - Retry cannot free the resource. The banner cleared for the 10-second
grace and then came back. Users had no way to fix it.
> - This pull request projects a scheduled resource wait as `working`,
with no Retry.
> - The benefit is that the failed-attempt banner shows only for real
failures.

## Linked Issues or Issue Description

**What happened?**
During onboarding on Paperclip Cloud staging (Claude subscription and
Codex subscription paths), the onboarding task showed "Couldn't finish
this attempt. Your message is saved." with a Retry button. The banner
did not name an attempt. Retry cleared it briefly, then it came back.

**Steps to reproduce**
1. Start a task run while another run holds the same workspace.
2. The run is cancelled with `workspace_busy`, and a `scheduled_retry`
run is created with `scheduledRetryReason: "workspace_busy"`.
3. Keep the workspace busy after `scheduledRetryAt`. On the staging
company, one retry stayed scheduled for 16 minutes past its retry time.
4. Open the task. The failed-attempt banner shows. Click Retry. The
banner comes back.

**Expected behavior**
No failed-attempt banner while the system waits for a shared resource.
The run status already says "Waiting for workspace"
(`execution-projection.ts`).

## What Changed

- `server/src/services/task-interruption-presentation.ts`: a
`scheduled_retry` run whose `scheduledRetryReason` is `workspace_busy`,
`ai_connection_busy`, `ai_connection_pool_wait` or
`runner_revision_wait` (and that has no system recovery) projects as
`state: "working"`, `canRetry: false`. User gates and failed Retry
preparations stay authoritative because they are checked first.
- The batched query now selects `scheduledRetryReason`.
- Tests: a Postgres integration test (`task-interruption-query.test.ts`)
that seeds the staging run chain for each reason, and unit tests for the
guard cases (failure-lane retries, paused agent, failed Retry
preparation).

## Verification

Red on `master` (fix not applied), `cd server && npx vitest run
src/__tests__/task-interruption-query.test.ts -t "overdue"`:

```
     × does not present an overdue workspace_busy wait as a failed attempt 213ms
     × does not present an overdue ai_connection_busy wait as a failed attempt 25ms
     × does not present an overdue ai_connection_pool_wait wait as a failed attempt 13ms
     × does not present an overdue runner_revision_wait wait as a failed attempt 11ms
- Expected
+ Received
-   "canRetry": false,
+   "canRetry": true,
-   "state": "working",
+   "state": "failed",
      Tests  4 failed | 11 skipped (15)
```

Green with the fix, same tests plus the new unit tests:

```
 ✓ task-interruption-query.test.ts > does not present an overdue workspace_busy wait as a failed attempt
 ✓ task-interruption-query.test.ts > does not present an overdue ai_connection_busy wait as a failed attempt
 ✓ task-interruption-query.test.ts > does not present an overdue ai_connection_pool_wait wait as a failed attempt
 ✓ task-interruption-query.test.ts > does not present an overdue runner_revision_wait wait as a failed attempt
 ✓ task-interruption-presentation.test.ts > scheduled resource wait presentation > keeps an overdue workspace wait out of the failed-attempt UI
 ✓ task-interruption-presentation.test.ts > scheduled resource wait presentation > keeps failure retries, user gates and failed Retry preparation authoritative
```

Neighbouring suites: `task-interruption-presentation`,
`task-interruption-query`, `workspace-recovery`, `agent-project-access`,
`heartbeat-dependency-scheduling`: 5 files, 458 tests passed.

Server `tsc --noEmit` reports no errors in changed files. The only
errors locally come from the unbuilt `@paperclipai/paperclip-runner`
vendor package, which this change does not touch. No UI change, so `pnpm
check:token-gates` is not applicable.

Evidence: the staging onboarding task's run history shows
`workspace_busy` cancellations at 17:46, 17:52 and 18:10, each followed
by a `scheduled_retry`. One had `scheduledRetryAt` 17:53:57 and started
at 18:10:20. The next run succeeded at 18:20.

## Risks

- Low risk. Presentation only; dispatch and admission do not change.
- If a resource wait never clears, the task shows no failed-attempt
banner. The run status still shows "Waiting for workspace", and the
existing retry-attempt limits still end the wait.
- Not in scope: genuine failures without a provider or workspace
explanation still show the generic text, and an overdue failure-lane
retry still shows as failed.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, browser for read-only staging evidence).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:08:27 -06:00

283 lines
19 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { projectTaskInterruption } from "./task-interruption-presentation.js";
const issue = { id: "task", companyId: "11111111-1111-4111-8111-111111111111", status: "blocked", assigneeAgentId: "agent", projectId: "22222222-2222-4222-8222-222222222222" };
const run = { id: "run", agentId: "agent", status: "failed", scheduledRetryAt: null,
errorCode: "project_access_denied", error: null, livenessState: null,
resultJson: { projectAccessDenied: { projectId: "22222222-2222-4222-8222-222222222222" } } };
const agent = { status: "idle", pauseReason: null };
describe("queued dependency wait presentation", () => {
const waitingRun = { ...run, status: "cancelled", runtimeMode: "legacy" as const, startedAt: null,
errorCode: "issue_dependencies_blocked", resultJson: {
stopReason: "issue_dependencies_blocked", timeoutSource: "dependency_gate",
cancellation: { source: "unknown", expected: false },
} };
it("keeps a dependency-gated wake out of the failed-attempt UI, including retained input", () => {
for (const wait of [undefined, { id: "saved-input", requestedAt: new Date(0) }]) {
expect(projectTaskInterruption(issue, { run: waitingRun, agent, wait, paused: false, approval: false }))
.toMatchObject({ state: "resolved", canRetry: false });
}
});
it.each(["legacy", "native"])("keeps an unstarted %s dispatcher dependency wait neutral", runtimeMode => {
const dispatchedWait = { ...waitingRun, runtimeMode,
resultJson: { ...waitingRun.resultJson, timeoutSource: "stale_queued_run_gate" } };
expect(projectTaskInterruption(issue, { run: dispatchedWait, agent, paused: false, approval: false }))
.toMatchObject({ state: "resolved", canRetry: false });
expect(projectTaskInterruption(issue, { run: { ...dispatchedWait, startedAt: new Date() }, agent, paused: false, approval: false }))
.toMatchObject({ state: "failed", canRetry: true });
});
it("keeps genuine failures and current user gates visible", () => {
for (const changed of [{ status: "failed" }, { runtimeMode: "native" }, { startedAt: new Date() },
{ errorCode: "setup_failed" }, { resultJson: {} }, { resultJson: { ...waitingRun.resultJson, timeoutFired: true } }]) {
expect(projectTaskInterruption(issue, { run: { ...waitingRun, ...changed }, agent, paused: false, approval: false }))
.toMatchObject({ state: "failed", canRetry: true });
}
expect(projectTaskInterruption(issue, { run: waitingRun, agent, paused: true, approval: false }))
.toMatchObject({ state: "waiting_for_user", userAction: "resume" });
expect(projectTaskInterruption(issue, { run: waitingRun, agent, paused: false, approval: true }))
.toMatchObject({ state: "waiting_for_user", userAction: "approval" });
expect(projectTaskInterruption(issue, { run: waitingRun, agent, paused: false, approval: false,
action: { evidence: {}, status: "active" } as never })).toMatchObject({ state: "failed", canRetry: true });
expect(projectTaskInterruption(issue, { run: waitingRun, agent, paused: false, approval: false,
retryWait: { id: "retry", sourceRunId: waitingRun.id, requestedAt: new Date(), preparationStatus: "failed" } }))
.toMatchObject({ state: "failed", canRetry: true });
});
});
describe("unadmitted chat Retry presentation", () => {
const now = Date.parse("2026-01-01T12:00:00Z");
const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null };
const wait = { id: "retry", requestedAt: new Date(now), sourceRunId: run.id,
preparationStatus: "issued", preparationRetryAt: now + 250 };
const input = { run: failed, agent, wait, paused: false, approval: false };
it.each(["issued", "processing"])("uses the persisted %s outbox deadline without implying provider work", status => {
for (const offset of [0, 20_000]) expect(projectTaskInterruption(issue, {
...input, wait: { ...wait, preparationStatus: status },
}, now + offset)).toMatchObject({ state: "retry_scheduled", canRetry: false,
nextRetryAt: new Date(wait.preparationRetryAt).toISOString() });
});
it.each(["failed", "cancelled"])("keeps terminal %s preparation out of the working state", status => {
expect(projectTaskInterruption(issue, { ...input, wait: { ...wait, preparationStatus: status,
preparationRetryAt: null } }, now)).toMatchObject({ state: "failed", nextRetryAt: null });
});
it("keeps current gates and admitted execution authoritative", () => {
expect(projectTaskInterruption(issue, { ...input, paused: true }, now))
.toMatchObject({ state: "waiting_for_user", userAction: "resume" });
expect(projectTaskInterruption(issue, { ...input, approval: true }, now))
.toMatchObject({ state: "waiting_for_user", userAction: "approval" });
for (const status of ["queued", "running"]) expect(projectTaskInterruption(issue, {
...input, run: { ...failed, id: "successor", status },
}, now)).toMatchObject({ state: "working", nextRetryAt: null });
expect(projectTaskInterruption(issue, { ...input, wait: { ...wait, sourceRunId: "old-run" } }, now))
.toMatchObject({ state: "working", nextRetryAt: null });
});
});
it("explains a persisted Retry wait without exposing the saved payload", () => {
const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null };
const wait = { id: "retry", requestedAt: new Date(), sourceRunId: failed.id,
executionWaitReason: "workspace_repair_required" };
const presentation = projectTaskInterruption(issue, { run: failed, agent, wait, paused: false, approval: false });
expect(presentation).toMatchObject({ state: "failed", canRetry: true,
retryWait: { reason: "workspace_repair_required", message: "Retry is waiting for Paperclip to recover the saved files. Your message is saved." } });
expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, sourceRunId: "another-run" }, paused: false, approval: false }))
.not.toHaveProperty("retryWait");
expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, executionWaitReason: "secret-payload" }, paused: false, approval: false }))
.toMatchObject({ retryWait: { reason: "execution_recovery", message: "Retry is waiting for execution checks. Your message is saved." } });
expect(projectTaskInterruption(issue, { run: failed, agent, wait, paused: true, approval: false }))
.toMatchObject({ state: "waiting_for_user", userAction: "resume" });
});
it.each(["cleanup_quarantined", "cancelled_by_operator"])("does not offer Retry for terminal Retry waits: %s", reason => {
const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null };
expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false,
wait: { id: "retry", requestedAt: new Date(), sourceRunId: failed.id, executionWaitReason: reason } }))
.toMatchObject({ state: "failed", canRetry: false, retryWait: { reason } });
});
it("preserves the actual workspace preparation error without changing retry authority", () => {
const failedRun = { ...run, errorCode: "workspace_validation_failed",
error: "The selected project has no repository source for a remote workspace.", resultJson: null };
expect(projectTaskInterruption(issue, { run: failedRun, agent, paused: false, approval: false }))
.toMatchObject({ state: "failed", canRetry: true, workspaceFailure: { message: failedRun.error } });
for (const status of ["done", "cancelled"]) expect(projectTaskInterruption({ ...issue, status },
{ run: failedRun, agent, paused: false, approval: false })).not.toHaveProperty("workspaceFailure");
expect(projectTaskInterruption(issue, { run: { ...failedRun, errorCode: "unrelated_failure" }, agent, paused: false, approval: false }))
.not.toHaveProperty("workspaceFailure");
});
function operatorStoppedRun() {
return { ...run, status: "cancelled", runtimeMode: "native" as const, companyId: issue.companyId,
nativeIssueId: issue.id, errorCode: "cancelled", resultJson: {
cancelledByActorType: "user", cancelledByUserId: "board",
executionCancellation: { state: "requested" },
nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId: run.id,
companyId: issue.companyId, issueId: issue.id, scope: "run",
reasonCode: "cancellation_run_only", dispatched: true, dispatchState: "acknowledged",
intentAuditId: "intent", acknowledgementAuditId: "ack" },
} };
}
describe("intentional native Stop presentation", () => {
const workingIssue = { ...issue, status: "in_progress" };
it("does not offer a failed-attempt Retry for an audited operator Stop that keeps its task in progress", () => {
expect(projectTaskInterruption(workingIssue, { run: operatorStoppedRun(), agent, paused: false, approval: false }))
.toMatchObject({ state: "resolved", canRetry: false, userAction: null });
expect(workingIssue.status).toBe("in_progress");
});
it.each([{ runId: "foreign" }, { companyId: "foreign" }, { issueId: "foreign" },
{ dispatchState: "pending" }, { dispatched: false }, { acknowledgementAuditId: undefined },
{ intentAuditId: 123 }, { acknowledgementAuditId: {} },
{ scope: "subtree" }])("retains failure presentation without the matching operator receipt %j", change => {
const stopped = operatorStoppedRun();
expect(projectTaskInterruption(workingIssue, { run: { ...stopped, resultJson: { ...stopped.resultJson,
nativeCancellation: { ...stopped.resultJson.nativeCancellation, ...change } } }, agent, paused: false, approval: false }))
.toMatchObject({ state: "failed", canRetry: true });
});
it("keeps pause, approval, non-user cancellation and new input authoritative", () => {
const stopped = operatorStoppedRun();
expect(projectTaskInterruption(workingIssue, { run: stopped, agent, paused: true, approval: false }))
.toMatchObject({ state: "waiting_for_user", userAction: "resume" });
expect(projectTaskInterruption(workingIssue, { run: stopped, agent, paused: false, approval: true }))
.toMatchObject({ state: "waiting_for_user", userAction: "approval" });
expect(projectTaskInterruption(workingIssue, { run: { ...stopped, resultJson: {
...stopped.resultJson, cancelledByActorType: "system" } }, agent, paused: false, approval: false }))
.toMatchObject({ state: "failed", canRetry: true });
expect(projectTaskInterruption(workingIssue, { run: stopped, agent, paused: false, approval: false,
wait: { id: "new-input", requestedAt: new Date() } }))
.toMatchObject({ state: "working", canRetry: false });
});
});
it("preserves a provider explanation on a failed task without changing retry authority", () => {
const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "anthropic", phase: "startup",
category: "rate_limit", message: "API error: 429 This request would exceed your account's rate limit.",
httpStatus: 429, retryability: "unknown" };
const failedRun = { ...run, errorCode: "native_session_interrupted", resultJson: { providerFailure } };
const presentation = projectTaskInterruption(issue, { run: failedRun, agent, paused: false, approval: false });
expect(presentation).toMatchObject({ state: "failed", canRetry: true, providerFailure });
expect(projectTaskInterruption({ ...issue, status: "done" }, { run: failedRun, agent, paused: false, approval: false }))
.not.toHaveProperty("providerFailure");
});
describe("task project-access interruption", () => {
it("explains a project denial without offering a futile retry", () => {
expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: false })).toMatchObject({
state: "waiting_for_user", responsibility: "user", userAction: "project_access", canRetry: false,
projectAccess: { agentId: "agent", projectId: "22222222-2222-4222-8222-222222222222", ready: false },
});
});
it("projects the historical setup denial without requiring another failed run", () => {
expect(projectTaskInterruption(issue, { run: { ...run, errorCode: "setup_failed",
error: "Project is outside this low-trust boundary.", resultJson: null }, agent, paused: false, approval: false }))
.toMatchObject({ userAction: "project_access", canRetry: false });
});
it("offers an explicit retry after the project is added, without changing the failed run", () => {
expect(projectTaskInterruption(issue, { run, agent, projectAccessAllowed: true, paused: false, approval: false }))
.toMatchObject({ state: "failed", canRetry: true, projectAccess: { ready: true } });
expect(run.status).toBe("failed");
});
it("shows an accepted retry as working after access is repaired", () => {
const input = { run, agent, paused: false, approval: false, wait: { id: "request", requestedAt: new Date() } };
expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true }))
.toMatchObject({ state: "working", canRetry: false });
expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true })).not.toHaveProperty("projectAccess");
expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: false }))
.toMatchObject({ state: "waiting_for_user", userAction: "project_access", canRetry: false });
});
it("does not reinterpret unrelated failures or an old assignee's denial", () => {
for (const otherRun of [{ ...run, errorCode: "setup_failed", error: "Disk full", resultJson: null },
{ ...run, agentId: "previous-agent" }]) {
expect(projectTaskInterruption(issue, { run: otherRun, agent, paused: false, approval: false }))
.not.toHaveProperty("projectAccess");
}
});
it("keeps approval, pause, running and completed states authoritative", () => {
expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: true })).toMatchObject({ userAction: "approval" });
expect(projectTaskInterruption(issue, { run, agent, paused: true, approval: false })).toMatchObject({ userAction: "resume" });
expect(projectTaskInterruption(issue, { run: { ...run, status: "running" }, agent, paused: false, approval: false }))
.toMatchObject({ state: "working" });
expect(projectTaskInterruption({ ...issue, status: "done" }, { run, agent, paused: false, approval: false }))
.toMatchObject({ state: "resolved" });
});
});
describe("saved-work recovery step", () => {
const now = Date.parse("2026-01-01T12:00:00Z");
const failed = { ...run, errorCode: "setup_failed", resultJson: null };
const system = (phase: string, state: string, nextAttemptAt: number | null) => ({
schema: "paperclip.system-recovery.v1", policyVersion: 1, sourceRunId: "33333333-3333-4333-8333-333333333333", revision: 3,
generationId: "44444444-4444-4444-8444-444444444444", intentId: null, phase, state, attemptId: null, attempts: 2,
retriesUsed: 1, firstFailureAt: now - 60_000, nextAttemptAt, deadlineAt: now + 600_000, providerCooldownAt: null,
failure: null, preservationOperationIds: [], freshAttemptRunId: null });
const action = (evidence: Record<string, unknown>) => ({ evidence }) as never;
it("names the step a scheduled recovery will retry", () => {
expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false,
action: action({ systemRecovery: system("verify_execution", "scheduled", now + 30_000) }) }, now))
.toMatchObject({ state: "retry_scheduled", recoveryStep: "checking_previous_run" });
});
it("names the step a running recovery is on", () => {
expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false,
action: action({ systemRecovery: system("preserve_files", "running", null), systemRecoveryActiveAt: now - 1_000 }) }, now))
.toMatchObject({ state: "working", recoveryStep: "saving_previous_files" });
});
it("names recovery over a pending chat preparation and keeps failed preparation authoritative", () => {
const wait = { id: "retry", requestedAt: new Date(now), sourceRunId: failed.id, preparationStatus: "issued", preparationRetryAt: now + 250 };
const running = action({ systemRecovery: system("preserve_files", "running", null), systemRecoveryActiveAt: now - 1_000 });
expect(projectTaskInterruption(issue, { run: failed, agent, wait, paused: false, approval: false, action: running }, now))
.toMatchObject({ state: "working", recoveryStep: "saving_previous_files" });
const scheduled = action({ systemRecovery: system("verify_execution", "scheduled", now + 30_000) });
expect(projectTaskInterruption(issue, { run: failed, agent, wait, paused: false, approval: false, action: scheduled }, now))
.toMatchObject({ state: "retry_scheduled", recoveryStep: "checking_previous_run", canRetry: false });
expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false, action: scheduled }, now))
.toMatchObject({ state: "retry_scheduled", canRetry: true });
expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, preparationStatus: "failed" },
paused: false, approval: false, action: running }, now)).toMatchObject({ state: "failed" });
expect(projectTaskInterruption(issue, { run: failed, agent, wait, paused: false, approval: false }, now))
.toMatchObject({ state: "retry_scheduled", nextRetryAt: new Date(now + 250).toISOString() });
});
it("does not name a step for an ordinary scheduled retry", () => {
expect(projectTaskInterruption(issue, { run: { ...failed, scheduledRetryAt: new Date(now + 30_000) }, agent,
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 });
});
});