Files
PaperClipAI/server/src/services/task-interruption-presentation.test.ts
T
DottaandClaude Opus 5.5 08f7ac03dc fix(recovery): keep a never-started task waiting through a temporary accounting hold (#19702)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Admission checks every wake. A short company accounting hold can
refuse new work after a control-plane restart.
> - When the hold refused a new task's assignment wake, stranded-work
recovery found the task with no run while the hold was still active. It
moved the task to `blocked` with a board recovery comment.
> - A `blocked` task leaves the stranded-assignment sweep, so nothing
woke it after the hold cleared. The task stayed `blocked` with no run,
no interruption and no Retry.
> - The hold was temporary. Tasks created a few seconds later (after the
sweep) started normally once accounting settled.
> - This pull request keeps a never-started task `todo` while a
temporary budget hold lasts. The existing assigned-todo liveness
dispatch then admits it once the hold clears. The task shows the hold it
waits for.
> - The benefit is that a restart no longer silently drops new tasks
created during the accounting settle window.

## Linked Issues or Issue Description

**What happened?**
In a restart-chaos storm on master `06bbcea12f`, three new tasks were
created while the company accounting hold was active. Each assignment
wake got 409 "Company cannot start work while completed runs await
accounting." (or "...native run recovers its unfinished accounting.").
One task rested `blocked` with 0 runs, interruption `null` and
`canRetry=false`. The other two ran later.

**Root cause**
`reconcileStrandedAssignedIssues`
(`server/src/services/recovery/service.ts`, the
`readInvocationBudgetBlock` branch near line 4826) escalates any
assigned task to `blocked` when a budget block exists. It does this
before the assigned-todo no-run branch, which already skips a
budget-blocked task and dispatches it later. So a task that never
started was treated as failed recovery work. The refused wake itself is
stored as a `skipped` `budget.blocked` diagnostic, which is correct; the
escalation removed the only path that re-admits it.

**Expected behavior**
A wake refused by a temporary admission hold waits and is admitted once
the hold clears, with one run. A real hard stop is still a board
decision. Removed assignees and cancelled tasks still do not wake.

**Steps to reproduce**
Create a company with a hard-stop budget and a completed run that has
`cost_accounting_pending = true`. Assign a new `todo` task. The wake
gets 409. Run the stranded-assignment sweep. Before this change the task
becomes `blocked` and never runs.

Related: #19700 bounds the accounting hold itself. This PR is
independent: it handles what happens to a task whose first wake the hold
refused.

## What Changed

- Stranded-work recovery skips a `todo` task with no run while a
temporary budget hold blocks admission. It does not escalate it to
`blocked`. The existing assigned-todo liveness dispatch admits the task
once the hold clears.
- A hard stop (spend at the limit, or blocked unpriced usage) keeps the
existing board escalation. Recovery checks this with the read-only
`policyBlocks` for the policies of the blocking scope. It does not
change budget hold semantics.
- The task interruption projection now covers a `todo` task with no run
whose latest wake was refused with `budget.blocked`. It shows "Waiting
for the budget to allow new work. The task starts automatically when it
does." with no Retry. A budget pause shows the existing "budget" user
action.
- The no-run projection applies the same person-owned gates as the
run-backed projection: a budget pause shows the "budget" action, and an
ordinary agent, project, company or task pause shows "resume". Only an
accounting hold shows the automatic-start text.
- `ExecutionBlockerNotice` shows that wait text for a `working`
presentation that carries a wait reason.
- `doc/execution-semantics.md` records the rule.

## Verification

- New embedded-Postgres regression
`server/src/__tests__/heartbeat-refused-assignment-wake.test.ts`:
- hold active, new task assigned, wake refused (409, `skipped`
`budget.blocked`), two recovery sweeps: the task stays `todo` with no
run and shows the wait reason. Before the fix this failed with `expected
'blocked' to be 'todo'` (the CHA-9 shape).
- hold cleared, two sweeps: exactly one provider turn starts, from
`issue.assigned_todo_liveness_dispatch`, and the task finishes. A later
sweep does not start another turn.
- counter-tests: a terminated assignee and a cancelled task get no run
after the hold clears.
- Existing `creates a board recovery action for budget-blocked assigned
work` (real hard stop) still passes unchanged.
- `pnpm exec vitest run
src/__tests__/heartbeat-refused-assignment-wake.test.ts
src/__tests__/heartbeat-process-recovery.test.ts
src/services/task-interruption-presentation.test.ts
src/__tests__/heartbeat-issue-liveness-escalation.test.ts` (server) and
`src/components/ExecutionBlockerNotice.test.tsx` (ui).
- Server typecheck and `pnpm check:token-gates`.
- Live restart-chaos storm (local, codex/claude/opencode, 6 in flight,
K2 every 3-4 minutes for 20 minutes, no RC-06 mask), revision
e38bb5c8f7: 17 tasks, 10 pass.
- Two new tasks (CHA-16, CHA-17) were created while the company
accounting hold was active. Their assignment wakes got 409 "Company
cannot start work while completed runs await accounting." Both stayed
`todo` (not `blocked`) and showed `state: working`, `canRetry: false`,
`retryWait.reason: budget.blocked`, "Waiting for the budget to allow new
work. The task starts automatically when it does." No task rested
`blocked` with 0 runs.
- The hold itself never cleared in this wave (`pendingRunCount: 1` at
wave end), so these two tasks did not start before their deadline. That
stuck hold is the separate accounting-closure defect that #19700 bounds.
The other 5 failures (`R3:retry_no_new_run:B5`) are Retries refused by
the same stuck hold. The re-admission after the hold clears is proved by
the embedded-Postgres regression above.

## Risks

- Low. The change narrows one escalation to tasks that never started,
under a budget block that is not a hard stop. Those tasks now stay
`todo` and are re-checked by the existing 30-second stranded-assignment
sweep, which already re-checks every admission gate.
- The hard-stop check reads the blocking scope's active policies once
per budget-blocked never-started task in a sweep. It runs only while a
hold is active.
- The interruption projection adds one indexed read (company + payload
issue id) for `todo` tasks in the batch.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, test execution).

## 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
- [ ] 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-06 21:12:00 -06:00

346 lines
23 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("never-started task refused by admission", () => {
const todo = { ...issue, status: "todo" };
const admissionWait = { id: "wake", status: "skipped", reason: "budget.blocked", agentId: "agent" };
it("names an accounting hold as an automatic wait without a Retry", () => {
expect(projectTaskInterruption(todo, { agent, admissionWait, paused: false, approval: false })).toMatchObject({
responsibility: "system", state: "working", canRetry: false,
retryWait: { reason: "budget.blocked", message: expect.stringContaining("starts automatically") } });
});
it("keeps pauses and budget stops as user actions instead of promising an automatic start", () => {
for (const [state, action] of [
[{ project: { pausedAt: new Date(), pauseReason: null } }, "resume"],
[{ company: { status: "paused", pauseReason: "manual" } }, "resume"],
[{ paused: true }, "resume"],
[{ agent: { status: "paused", pauseReason: "manual" } }, "resume"],
[{ company: { status: "paused", pauseReason: "budget" } }, "budget"],
[{ approval: true }, "approval"],
] as const) {
expect(projectTaskInterruption(todo, { agent, admissionWait, paused: false, approval: false, ...state }))
.toMatchObject({ responsibility: "user", state: "waiting_for_user", userAction: action });
}
});
it("shows nothing for other refusals, started tasks or another assignee", () => {
for (const changed of [{ reason: "agent.not_invokable" }, { status: "queued" }, { agentId: "other" }]) {
expect(projectTaskInterruption(todo, { agent, admissionWait: { ...admissionWait, ...changed }, paused: false, approval: false }))
.toBeNull();
}
expect(projectTaskInterruption({ ...todo, status: "cancelled" }, { agent, admissionWait, paused: false, approval: false })).toBeNull();
});
});
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 });
});
});
// The native coordinator resumes the same run itself after an attach failure.
// A manual Retry offered meanwhile would race that automatic resume.
it("shows a pending native same-run resume instead of a competing Retry", () => {
const now = Date.parse("2026-11-06T15:25:50.000Z");
const failed = { ...run, runtimeMode: "native" as const, errorCode: "native_session_interrupted", resultJson: null };
const resumeAt = new Date("2026-11-06T15:26:18.687Z");
expect(projectTaskInterruption(issue, { run: { ...failed, nativeResumeAt: resumeAt }, agent, paused: false, approval: false }, now))
.toMatchObject({ state: "retry_scheduled", canRetry: false, nextRetryAt: resumeAt.toISOString() });
// A due resume is being dispatched; it is still not the user's turn.
expect(projectTaskInterruption(issue, { run: { ...failed, nativeResumeAt: new Date(now - 30_000) }, agent, paused: false, approval: false }, now))
.toMatchObject({ state: "working", canRetry: false });
// A resume that never ran, or no resume at all, leaves exactly one Retry.
expect(projectTaskInterruption(issue, { run: { ...failed, nativeResumeAt: new Date(now - 10 * 60_000) }, agent, paused: false, approval: false }, now))
.toMatchObject({ state: "failed", canRetry: true });
expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false }, now))
.toMatchObject({ state: "failed", canRetry: true });
});
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("names a budget gate instead of generic execution checks for a refused Retry", () => {
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: "budget.blocked" } }))
.toMatchObject({ state: "failed", canRetry: true, retryWait: { reason: "budget.blocked",
message: "Retry is waiting for the budget to allow new work. Check Costs for unrecorded or unpriced usage." } });
});
it.each(["cleanup_quarantined", "cancelled_by_operator", "retry_source_changed"])("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("keeps Retry for a Retry declined for its requester's access, since a new click replaces it", () => {
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: "retry_authority_denied" } }))
.toMatchObject({ state: "failed", canRetry: true, retryWait: { reason: "retry_authority_denied" } });
});
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 });
});
});