mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - When a task's automatic recovery stops, the person's Retry rechecks
the task's gates. If a gate refuses, for example a budget hard stop, the
Retry is saved and starts by itself once the gate allows it
> - After a person pressed Retry under a budget hold, the task page went
blank: no failure, no notice and no Retry. A person had no way to see
why nothing started
> - Two gaps caused it. The task notice drew only system-owned states,
and a budget hold presents as a person-owned state with no card of its
own. Also, admission saved the wait reason without publishing anything
that an open page listens to
> - This pull request keeps the failure visible and names the budget
wait, and it refreshes an open task when admission records why a Retry
waits
> - The benefit is that "failure remains visible if the new attempt
cannot start" holds without the person reloading the page
> - These two commits were pushed to #20306 after it was squash-merged,
so they are not on master
## Linked Issues or Issue Description
Refs #20306 (merged without these two commits) and #20270.
**What happened?**
In the chat eval
`chat-features.runner-opencode.local.recovery.retry-exhausted`, a person
pressed Retry on a task whose native recovery was exhausted while the
agent's budget was held. The Retry was accepted (`POST
/api/issues/{id}/retry` returned 202). The task page then stayed blank
for more than 60 seconds on the open page.
**Expected behavior**
The task keeps the failure visible and says that it is waiting for the
budget. Once the budget allows new work, the saved Retry starts once by
itself.
**Steps to reproduce**
1. Let a task's run fail, and put the assignee over a budget hard stop.
2. Open the task and press Retry.
3. The page shows nothing about the failure or the wait.
**Paperclip version or commit**
master at 76254ead22
## What Changed
- `ExecutionBlockerNotice.tsx`: a Retry held by a budget hard stop
presents as `waiting_for_user` with `userAction: "budget"`. The notice
now shows "Couldn't finish this attempt. Your message is saved. It is
waiting for the budget to allow new work. Raise the budget in Costs."
(`data-testid="task-execution-waiting"`). It shows no second Retry,
because the saved Retry starts by itself. Other person-owned decisions,
such as approvals and run-stopped confirmation, keep their own surfaces.
- `deferred-execution-wait.ts`: when the wait reason of a deferred
request changes, its task records an `issue.execution_waiting` activity.
Its live event refreshes an open task page. Callers inside a transaction
(`heartbeat/queue.ts`) pass their post-commit publication list, so the
event is published only after commit.
## Verification
- A probe on the real deferred-receipt fixture showed that a budget-held
Retry presents as `waiting_for_user` / `budget` / `canRetry: false` at 0
and 11 seconds. Before this change, the notice rendered nothing for that
state.
- `ExecutionBlockerNotice.test.tsx`: 18 tests pass. The budget-held
notice is covered, it offers no duplicate Retry, and approvals still
render nothing here.
- `deferred-execution-wait.test.ts` (new, embedded PostgreSQL):
- One live event is published for each new wait reason. The same reason
again publishes nothing.
- Inside a caller's transaction, nothing is published before commit.
- A request that is no longer deferred records nothing.
- Two of these cases fail when the change is reverted.
- `deferred-execution-receipt-work.test.ts`: passes (49 tests together
with the new file).
- `pnpm check:token-gates`: clean.
- Paid live run: `recovery.retry-exhausted` on an eval branch that
includes both commits. The run ID will be added below.
## Risks
- Low. The new notice renders only for `waiting_for_user` with
`userAction: "budget"`, which rendered nothing before.
- The activity row is written only when a deferred request's wait reason
changes, so a gate that refuses repeatedly adds one row, not one row for
each evaluation.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use and code 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
- [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>
185 lines
13 KiB
TypeScript
185 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act } from "react";
|
||
import { createRoot, type Root } from "react-dom/client";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
|
||
import type { AnchorHTMLAttributes } from "react";
|
||
import { ExecutionBlockerNotice } from "./ExecutionBlockerNotice";
|
||
|
||
vi.mock("@/lib/router", () => ({
|
||
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => <a href={to} {...props}>{children}</a>,
|
||
}));
|
||
|
||
const failed: TaskInterruptionPresentation = {
|
||
intentId: "intent", responsibility: "system", state: "failed", nextRetryAt: null,
|
||
canRetry: true, userAction: null, sourceRunIds: ["source"], observedRunId: "source",
|
||
};
|
||
describe("ordinary task interruption", () => {
|
||
let root: Root;
|
||
let container: HTMLDivElement;
|
||
const retry = vi.fn();
|
||
beforeEach(() => {
|
||
retry.mockReset(); container = document.createElement("div"); document.body.append(container);
|
||
root = createRoot(container);
|
||
});
|
||
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.useRealTimers(); });
|
||
const render = async (presentation = failed, options: { canRetry?: boolean; optimistic?: boolean; takingLonger?: boolean; error?: Error } = {}) => {
|
||
await act(async () => root.render(<ExecutionBlockerNotice presentation={presentation} canRetry onRetry={retry} {...options} />));
|
||
};
|
||
it("offers one Retry without delegating infrastructure chores", async () => {
|
||
await render();
|
||
expect(container.textContent).toContain("Couldn’t finish this attempt. Your message is saved.");
|
||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||
// A stable handle for the task's own Retry, the supported path after
|
||
// automatic recovery stops.
|
||
expect(container.querySelector('[data-testid="task-execution-retry"]')?.textContent).toBe("Retry");
|
||
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
||
expect(retry).toHaveBeenCalledOnce();
|
||
expect(container.querySelector("a, details, input, textarea")).toBeNull();
|
||
expect(container.textContent).not.toMatch(/service attention|process|receipt|workspace/i);
|
||
});
|
||
it("keeps the failure visible and names the gate while a saved Retry waits on the budget", async () => {
|
||
await render({ ...failed, responsibility: "user", state: "waiting_for_user", userAction: "budget", canRetry: false });
|
||
const notice = container.querySelector('[data-testid="task-execution-waiting"]');
|
||
expect(notice?.textContent).toContain("Couldn’t finish this attempt. Your message is saved.");
|
||
expect(notice?.textContent).toContain("waiting for the budget to allow new work");
|
||
// The saved Retry starts by itself; a second Retry would only duplicate it.
|
||
expect(container.querySelector("button")).toBeNull();
|
||
});
|
||
|
||
it("does not report a failed attempt for a task the budget held before its first run", async () => {
|
||
await render({ ...failed, responsibility: "user", state: "waiting_for_user", userAction: "budget", canRetry: false,
|
||
sourceRunIds: [], observedRunId: null });
|
||
const notice = container.querySelector('[data-testid="task-execution-waiting"]');
|
||
expect(notice?.textContent).toContain("This task has not started.");
|
||
expect(notice?.textContent).not.toContain("Couldn’t finish this attempt");
|
||
});
|
||
|
||
it("shows persisted backoff with a usable Retry", async () => {
|
||
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-20T00:00:00Z"));
|
||
await render({ ...failed, state: "retry_scheduled", nextRetryAt: "2026-10-20T00:00:30Z" });
|
||
expect(container.textContent).toContain("Trying again in 30s");
|
||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
|
||
expect(container.textContent).toContain("Trying again in 25s");
|
||
expect(container.querySelector("button")?.textContent).toBe("Retry");
|
||
});
|
||
it.each(["working", "resolved"] as const)("does not duplicate the thread indicator for %s", async state => {
|
||
await render({ ...failed, state }); expect(container.textContent).toBe("");
|
||
});
|
||
it("names the admission hold a task that has not started waits for", async () => {
|
||
await render({ ...failed, state: "working", canRetry: false, sourceRunIds: [], observedRunId: null,
|
||
retryWait: { reason: "budget.blocked", message: "Waiting for the budget to allow new work. The task starts automatically when it does." } });
|
||
expect(container.textContent).toBe("Waiting for the budget to allow new work. The task starts automatically when it does.");
|
||
expect(container.querySelector("button")).toBeNull();
|
||
});
|
||
it("names and links the readable blockers a saved Retry waits on, with no second Retry (restart chaos t05)", async () => {
|
||
const message = "Retry is waiting on a blocked task this one depends on. Retry or unblock it; this task continues when it is done. Your Retry is saved.";
|
||
const summary = (id: string, identifier: string, title: string, status: string) =>
|
||
({ id, identifier, title, status, priority: "medium", assigneeAgentId: null, assigneeUserId: null }) as never;
|
||
await act(async () => root.render(<ExecutionBlockerNotice presentation={{ ...failed, state: "working", canRetry: false,
|
||
retryWait: { reason: "dependency_blocked", message } }} canRetry onRetry={retry}
|
||
blockers={[summary("child", "CHA-10", "Part 2", "blocked"), summary("gone", "CHA-11", "Part 3", "cancelled"),
|
||
summary("done", "CHA-9", "Part 1", "done")]} />));
|
||
expect(container.textContent).toContain(message);
|
||
const links = [...container.querySelectorAll<HTMLAnchorElement>("[data-testid='retry-waiting-on'] a")].map(link => link.getAttribute("href"));
|
||
expect(links).toHaveLength(2);
|
||
expect(links[0]).toContain("CHA-10");
|
||
expect(container.textContent).toContain("Blocked: open it to Retry");
|
||
expect(container.textContent).toContain("Cancelled: remove or replace it");
|
||
expect(container.textContent).not.toContain("Part 1");
|
||
expect(container.querySelector("button")).toBeNull();
|
||
});
|
||
it("shows a locked blocker without reading its status, and a finished blocker still saving files", async () => {
|
||
const message = "Retry is waiting for a finished task this one depends on to save its files. Your Retry is saved.";
|
||
await act(async () => root.render(<ExecutionBlockerNotice presentation={{ ...failed, state: "working", canRetry: false,
|
||
retryWait: { reason: "dependency_blocked", message } }} canRetry onRetry={retry}
|
||
blockers={[{ id: "secret", identifier: "CHA-12", locked: true } as never]} />));
|
||
expect(container.querySelector("[data-testid='locked-issue-chip']")?.textContent).toContain("CHA-12");
|
||
await act(async () => root.render(<ExecutionBlockerNotice presentation={{ ...failed, state: "working", canRetry: false,
|
||
retryWait: { reason: "dependency_blocked", message } }} canRetry onRetry={retry}
|
||
blockers={[{ id: "done", identifier: "CHA-9", title: "Part 1", status: "done", priority: "medium",
|
||
assigneeAgentId: null, assigneeUserId: null } as never]} />));
|
||
expect(container.querySelector("[data-testid='retry-waiting-on'] a")?.getAttribute("href")).toContain("CHA-9");
|
||
expect(container.textContent).toContain("Saving its files");
|
||
});
|
||
it("names the saved-work recovery step instead of a bare queue", async () => {
|
||
await render({ ...failed, state: "working", canRetry: false, recoveryStep: "saving_previous_files" });
|
||
expect(container.textContent).toBe("Saving files from the previous attempt…");
|
||
expect(container.querySelector("button")).toBeNull();
|
||
});
|
||
it("names the step a scheduled recovery will retry", async () => {
|
||
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-20T00:00:00Z"));
|
||
await render({ ...failed, state: "retry_scheduled", nextRetryAt: "2026-10-20T00:00:30Z", recoveryStep: "checking_previous_run" });
|
||
expect(container.textContent).toContain("Checking that the previous attempt stopped. Trying again in 30s…");
|
||
expect(container.querySelector("button")?.textContent).toBe("Retry");
|
||
});
|
||
it("names the recovery step inside a provider failure notice", async () => {
|
||
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-20T00:00:00Z"));
|
||
await render({ ...failed, state: "retry_scheduled", nextRetryAt: "2026-10-20T00:00:30Z", recoveryStep: "checking_previous_run",
|
||
providerFailure: { schema: "paperclip.provider-failure.v1", provider: "openai", phase: "startup", category: "overloaded",
|
||
retryability: "unknown", message: "Try later." } });
|
||
expect(container.textContent).toContain("Checking that the previous attempt stopped. Trying again in 30s…");
|
||
expect(container.textContent).not.toContain("Paperclip will retry automatically.");
|
||
});
|
||
it("leaves genuine user decisions to their specific action surface", async () => {
|
||
await render({ ...failed, responsibility: "user", state: "waiting_for_user", userAction: "approval", canRetry: false });
|
||
expect(container.textContent).toBe("");
|
||
});
|
||
it("does not give read-only users a mutation", async () => {
|
||
await render(failed, { canRetry: false }); expect(container.querySelector("button")).toBeNull();
|
||
});
|
||
it("removes the stale failure immediately while admission is pending", async () => {
|
||
await render(failed, { optimistic: true }); expect(container.textContent).toBe("");
|
||
await render(failed, { optimistic: true, takingLonger: true });
|
||
expect(container.textContent).toContain("Taking longer than usual…");
|
||
});
|
||
it("shows a definitive admission rejection instead of silently dropping Retry", async () => {
|
||
await render(failed, { error: new Error("Your task access changed. Refresh the task.") });
|
||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Your task access changed.");
|
||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||
});
|
||
it("shows workspace preparation failures as plain text while keeping retry permissions", async () => {
|
||
const workspaceFailure = { message: 'Could not prepare the project folder <img src=x onerror=alert(1)>.' };
|
||
await render({ ...failed, workspaceFailure }, { canRetry: false });
|
||
expect(container.textContent).toContain("Couldn’t prepare the task folders.");
|
||
expect(container.textContent).toContain(workspaceFailure.message);
|
||
expect(container.querySelector("img, button")).toBeNull();
|
||
await render({ ...failed, workspaceFailure });
|
||
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
||
expect(retry).toHaveBeenCalledOnce();
|
||
await render({ ...failed, workspaceFailure, state: "working" });
|
||
expect(container.textContent).toBe("");
|
||
});
|
||
it("shows why an accepted Retry is waiting and keeps another attempt available", async () => {
|
||
const retryWait = { reason: "remote_cleanup", message: "Retry is waiting for the previous environment to stop. Your message is saved." };
|
||
await render({ ...failed, retryWait });
|
||
expect(container.textContent).toContain(retryWait.message);
|
||
expect(container.textContent).not.toContain("Couldn’t finish this attempt.");
|
||
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
||
expect(retry).toHaveBeenCalledOnce();
|
||
await render({ ...failed, retryWait }, { canRetry: false });
|
||
expect(container.textContent).toContain(retryWait.message);
|
||
expect(container.querySelector("button")).toBeNull();
|
||
});
|
||
it("names the budget wait alongside a provider failure while a saved Retry is held", async () => {
|
||
const providerFailure = { schema: "paperclip.provider-failure.v1" as const, provider: "openai", phase: "startup" as const,
|
||
category: "overloaded" as const, retryability: "transient" as const, message: "Provider overloaded" };
|
||
await render({ ...failed, providerFailure, responsibility: "user", state: "waiting_for_user", userAction: "budget", canRetry: false });
|
||
expect(container.textContent).toContain("Provider overloaded");
|
||
expect(container.querySelector('[data-testid="task-execution-waiting"]')?.textContent).toContain("waiting for the budget to allow new work");
|
||
expect(container.querySelector("button")).toBeNull();
|
||
});
|
||
it("shows an unknown provider's original explanation as text and preserves read-only access", async () => {
|
||
const providerFailure = { schema: "paperclip.provider-failure.v1" as const, provider: "new-provider", phase: "startup" as const,
|
||
category: "unknown" as const, retryability: "unknown" as const, httpStatus: 429,
|
||
message: "Upstream failure <img src=x onerror=alert(1)>" };
|
||
await render({ ...failed, providerFailure }, { canRetry: false });
|
||
expect(container.textContent).toContain(providerFailure.message);
|
||
expect(container.textContent).toContain("Failed before work started");
|
||
expect(container.textContent).toContain("HTTP 429");
|
||
expect(container.querySelector("img, button")).toBeNull();
|
||
await render({ ...failed, providerFailure, state: "resolved" });
|
||
expect(container.textContent).toBe("");
|
||
});
|
||
});
|