mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(tasks): keep a budget-held Retry's failure visible and refresh the open task (#20338)
## 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>
This commit is contained in:
1 parent
16e9157577
commit
93f7c3b5d3
5 files changed
+158
-6
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { activityLog, agents, agentWakeupRequests, companies, createDb, issues } from "@paperclipai/db";
|
||||
import type { LiveEvent } from "@paperclipai/shared";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
||||
import { publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
import { writeDeferredExecutionWait } from "./deferred-execution-wait.js";
|
||||
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
describe.skipIf(!support.supported)("deferred execution wait live refresh", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("deferred-execution-wait-");
|
||||
db = createDb(database.connectionString);
|
||||
}, 60_000);
|
||||
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
||||
|
||||
/** A person's Retry, saved as a deferred request that admission has not started. */
|
||||
async function deferredRetry() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Wait fixture", issuePrefix: companyId.slice(0, 8) });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Morgan", adapterType: "process", status: "active" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Website task", status: "blocked", assigneeAgentId: agentId });
|
||||
const [request] = await db.insert(agentWakeupRequests).values({ companyId, agentId, source: "on_demand",
|
||||
reason: "task_retry", status: "deferred_issue_execution", payload: { issueId } }).returning();
|
||||
return { companyId, agentId, issueId, requestId: request!.id };
|
||||
}
|
||||
|
||||
function listen(companyId: string) {
|
||||
const events: LiveEvent[] = [];
|
||||
const stop = subscribeCompanyLiveEvents(companyId, event => events.push(event));
|
||||
const waiting = () => events.filter(event => event.type === "activity.logged" && event.payload.action === "issue.execution_waiting");
|
||||
return { waiting, stop };
|
||||
}
|
||||
|
||||
const budget = { reason: "budget.blocked", message: "Retry is waiting for the budget to allow new work." };
|
||||
|
||||
it("refreshes an open task page once when admission records why a Retry waits", async () => {
|
||||
const f = await deferredRetry(), live = listen(f.companyId);
|
||||
try {
|
||||
expect(await writeDeferredExecutionWait(db, { companyId: f.companyId, requestId: f.requestId }, budget)).toBe(true);
|
||||
expect(live.waiting()).toHaveLength(1);
|
||||
expect(live.waiting()[0]!.payload).toMatchObject({ entityType: "issue", entityId: f.issueId, issueId: f.issueId,
|
||||
actorType: "system", agentId: f.agentId, details: { requestId: f.requestId, reason: "budget.blocked" } });
|
||||
// The same reason again changes nothing a reader can see: no repeated refresh.
|
||||
await writeDeferredExecutionWait(db, { companyId: f.companyId, requestId: f.requestId }, budget);
|
||||
expect(live.waiting()).toHaveLength(1);
|
||||
// A different gate does refresh the page.
|
||||
await writeDeferredExecutionWait(db, { companyId: f.companyId, requestId: f.requestId },
|
||||
{ reason: "issue_tree_hold_active", message: "This task is paused." }, { replace: true });
|
||||
expect(live.waiting()).toHaveLength(2);
|
||||
const rows = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId),
|
||||
eq(activityLog.action, "issue.execution_waiting")));
|
||||
expect(rows.map(row => (row.details as { reason: string }).reason)).toEqual(["budget.blocked", "issue_tree_hold_active"]);
|
||||
} finally { live.stop(); }
|
||||
});
|
||||
|
||||
it("publishes only after the caller's transaction commits when it passes its publication list", async () => {
|
||||
const f = await deferredRetry(), live = listen(f.companyId), publications: ActivityPublication[] = [];
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
await writeDeferredExecutionWait(tx as unknown as typeof db, { companyId: f.companyId, requestId: f.requestId }, budget, { publications });
|
||||
expect(live.waiting()).toHaveLength(0);
|
||||
});
|
||||
expect(publications).toHaveLength(1);
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
expect(live.waiting()).toHaveLength(1);
|
||||
} finally { live.stop(); }
|
||||
});
|
||||
|
||||
it("records nothing for a request that is no longer deferred", async () => {
|
||||
const f = await deferredRetry(), live = listen(f.companyId);
|
||||
try {
|
||||
await db.update(agentWakeupRequests).set({ status: "completed" }).where(eq(agentWakeupRequests.id, f.requestId));
|
||||
expect(await writeDeferredExecutionWait(db, { companyId: f.companyId, requestId: f.requestId }, budget)).toBe(false);
|
||||
expect(live.waiting()).toHaveLength(0);
|
||||
} finally { live.stop(); }
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { HttpError } from "../errors.js";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { agentWakeupRequests, type Db } from "@paperclipai/db";
|
||||
import { notifyDeferredExecutionWork } from "./deferred-execution-notifications.js";
|
||||
import { persistActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
|
||||
|
||||
/** Admission has saved this refusal on the caller's durable deferred receipt.
|
||||
* HTTP callers still receive the existing 409; receipt recovery waits for its
|
||||
@@ -10,17 +11,27 @@ export class PersistedExecutionWaitRefusal extends HttpError {
|
||||
constructor(message: string, details?: unknown) { super(409, message, details); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Save why a deferred request cannot start yet. When the reason changes, the
|
||||
* request's task also records an `issue.execution_waiting` activity. Its live
|
||||
* event refreshes an open task page, so a person who just pressed Retry sees
|
||||
* the kept failure and the named wait (for example the budget) without
|
||||
* reloading. Pass `publications` when the caller's transaction is still open;
|
||||
* they must be published after it commits.
|
||||
*/
|
||||
export async function writeDeferredExecutionWait(db: Db, scope: { companyId: string; requestId: string },
|
||||
condition: Record<string, unknown>, options: { replace?: boolean } = {}) {
|
||||
return db.transaction(async tx => {
|
||||
const [current] = await tx.select({ payload: agentWakeupRequests.payload }).from(agentWakeupRequests)
|
||||
condition: Record<string, unknown>, options: { replace?: boolean; publications?: ActivityPublication[] } = {}) {
|
||||
const pending: ActivityPublication[] = [];
|
||||
const saved = await db.transaction(async tx => {
|
||||
const [current] = await tx.select({ payload: agentWakeupRequests.payload, agentId: agentWakeupRequests.agentId }).from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, scope.companyId), eq(agentWakeupRequests.id, scope.requestId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"))).for("update");
|
||||
if (!current) return false;
|
||||
const previous = current.payload?.executionWait;
|
||||
const previousReason = previous && typeof previous === "object" && !Array.isArray(previous)
|
||||
? (previous as Record<string, unknown>).reason : undefined;
|
||||
if (!previous || previousReason !== condition.reason) await notifyDeferredExecutionWork(tx, scope);
|
||||
const reasonChanged = !previous || previousReason !== condition.reason;
|
||||
if (reasonChanged) await notifyDeferredExecutionWork(tx, scope);
|
||||
const [saved] = await tx.update(agentWakeupRequests).set({
|
||||
payload: sql`jsonb_set(coalesce(${agentWakeupRequests.payload}, '{}'::jsonb), '{executionWait}',
|
||||
${options.replace ? sql`'{}'::jsonb` : sql`coalesce(${agentWakeupRequests.payload}->'executionWait', '{}'::jsonb)`}
|
||||
@@ -28,6 +39,16 @@ export async function writeDeferredExecutionWait(db: Db, scope: { companyId: str
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(agentWakeupRequests.companyId, scope.companyId), eq(agentWakeupRequests.id, scope.requestId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"))).returning({ id: agentWakeupRequests.id });
|
||||
const issueId = current.payload?.issueId;
|
||||
if (saved && reasonChanged && typeof issueId === "string" && issueId && typeof condition.reason === "string") {
|
||||
const { publication } = await persistActivity(tx as unknown as Db, { companyId: scope.companyId, actorType: "system",
|
||||
actorId: "execution-admission", action: "issue.execution_waiting", entityType: "issue", entityId: issueId,
|
||||
agentId: current.agentId, details: { requestId: scope.requestId, reason: condition.reason } });
|
||||
pending.push(publication);
|
||||
}
|
||||
return Boolean(saved);
|
||||
});
|
||||
if (options.publications) options.publications.push(...pending);
|
||||
else for (const publication of pending) publishActivity(publication);
|
||||
return saved;
|
||||
}
|
||||
@@ -3107,7 +3107,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
// so the task explains it and a new click can replace this intent.
|
||||
const declineRetry = async (reason: string, message: string) => {
|
||||
await writeDeferredExecutionWait(tx as unknown as Db, { companyId: agent.companyId,
|
||||
requestId: pending.id }, { reason, message }, { replace: true });
|
||||
requestId: pending.id }, { reason, message }, { replace: true, publications: issueActivityPublications });
|
||||
logger.warn({ requestId: pending.id, issueId, reason }, "Task Retry could not start");
|
||||
return { kind: "deferred" as const };
|
||||
};
|
||||
@@ -3581,7 +3581,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
|
||||
const condition = { recoveryActionId: executionBlocker.recoveryActionId, ...continuationWait };
|
||||
if (executionWaitRequestId) {
|
||||
await writeDeferredExecutionWait(tx as unknown as Db, { companyId: agent.companyId,
|
||||
requestId: executionWaitRequestId }, condition, { replace: true });
|
||||
requestId: executionWaitRequestId }, condition, { replace: true, publications: issueActivityPublications });
|
||||
return { kind: "deferred" as const, admitted: true };
|
||||
}
|
||||
if (durableRequest || wakeCommentId ||
|
||||
|
||||
@@ -38,6 +38,23 @@ describe("ordinary task interruption", () => {
|
||||
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" });
|
||||
@@ -144,6 +161,14 @@ describe("ordinary task interruption", () => {
|
||||
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,
|
||||
|
||||
@@ -18,6 +18,17 @@ const recoveryStepText: Record<NonNullable<TaskInterruptionPresentation["recover
|
||||
finishing_recovery: "Finishing recovery of saved work",
|
||||
};
|
||||
|
||||
/** A budget hard stop holds saved work. Only a task that already ran has an
|
||||
* attempt to report; a task refused before its first run just waits. */
|
||||
function budgetWaitText(presentation: TaskInterruptionPresentation) {
|
||||
const wait = "It is waiting for the budget to allow new work. Raise the budget in Costs.";
|
||||
return presentation.observedRunId || presentation.sourceRunIds.length
|
||||
? `Couldn’t finish this attempt. Your message is saved. ${wait}`
|
||||
: `This task has not started. ${wait}`;
|
||||
}
|
||||
const isBudgetWait = (presentation: TaskInterruptionPresentation | null | undefined) =>
|
||||
presentation?.state === "waiting_for_user" && presentation.userAction === "budget";
|
||||
|
||||
const blockerAction: Record<string, string> = {
|
||||
blocked: "Blocked: open it to Retry",
|
||||
cancelled: "Cancelled: remove or replace it",
|
||||
@@ -55,6 +66,7 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
|
||||
return <ProviderFailureNotice failure={presentation.providerFailure} className={className}
|
||||
actions={canRetry && presentation.canRetry ? <Button variant="outline" size="sm" onClick={onRetry} data-testid="task-execution-retry">Retry</Button> : undefined}>
|
||||
{presentation.state === "retry_scheduled" && <p>{step ? `${step}. ${retryIn}` : "Paperclip will retry automatically."}</p>}
|
||||
{isBudgetWait(presentation) && <p data-testid="task-execution-waiting">It is waiting for the budget to allow new work. Raise the budget in Costs.</p>}
|
||||
</ProviderFailureNotice>;
|
||||
}
|
||||
if (step && presentation?.state === "working") return <div role="status" aria-label="Task status"
|
||||
@@ -89,6 +101,18 @@ export function ExecutionBlockerNotice({ presentation, canRetry, onRetry, optimi
|
||||
placeholder: "For example: no codex or claude process for this task is running on the server host.",
|
||||
resuming: "Starting your Retry…", confirm: "Confirm it stopped" }} />;
|
||||
}
|
||||
// Other person-owned decisions have their own card on the task (approval,
|
||||
// project access, ...). A budget hold has none, so name it here and keep the
|
||||
// failure visible. The saved attempt starts by itself once the budget allows.
|
||||
if (!optimistic && presentation && isBudgetWait(presentation)) return (
|
||||
<div className={cn("pt-4", className)}>
|
||||
<div role="status" aria-label="Task status" data-testid="task-execution-waiting"
|
||||
className="flex items-center gap-3 rounded-lg bg-destructive/10 p-4 text-sm text-foreground">
|
||||
<AlertCircle className="size-5 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1">{budgetWaitText(presentation)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (optimistic || !presentation || presentation.responsibility !== "system" ||
|
||||
!["failed", "retry_scheduled"].includes(presentation.state)) return null;
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user