mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 21:03:51 +02:00
fix(ui): retry the latest failed task attempt (#13765)
## Thinking Path > - Paperclip manages work done by AI agents. > - The task thread lets an operator retry a failed run. > - Legacy runs with transcript output did not get a failure marker. > - The thread could therefore offer Try again for an older failure. > - The server correctly reused that failure's existing retry, even when it had already failed. > - This change keeps the latest failure actionable and reports stopped retry responses to the operator. ## Linked Issues or Issue Description **What happened?** Try again could return success without starting work. An initial setup failure had an empty transcript. Its later retry produced output and failed. Only the initial failure had a retry marker, so the button kept requesting the initial failure's already-failed successor. **Expected behavior** Try again targets the latest failed attempt. An already-stopped retry response shows an error and refreshes the task's run state. **Steps to reproduce** 1. Start a legacy adapter task that fails before producing transcript output. 2. Retry it. Let this attempt produce output and a final failure comment before it fails. 3. Click Try again in the task thread. 4. Before this fix, the click targets the original failure and replays the stopped successor. **Paperclip version or commit** Reproduced against `1483bb8bcf`; the regression is also present on the branch base `8813a50105`. **Deployment mode** Authenticated server with a legacy adapter. The bug is in the shared task UI and retry API client. Related: #11650 adds a different recovery-notice action. This change fixes failed-run markers and retry response handling. Searches found no duplicate of this failure case. ## What Changed - Render legacy failure markers even when the run has a transcript or final comment. - Keep later cancelled automatic retries from replacing the failed run's retry action. - Reject already-stopped retry responses in the API client so existing error feedback appears. - Refresh task run queries after both successful and failed retry requests. - Add regression coverage for failed and timed-out attempts, execution gates with output, and retry response states. ## Verification - Before the fix, the new regression tests failed: two selected the original failure, and four accepted a stopped successor as success. - Targeted task-thread, retry API, marker, and issue-page tests: 279 passed. - `pnpm --filter @paperclipai/ui typecheck`: passed. - `pnpm --filter @paperclipai/ui build`: passed. - `pnpm check:token-gates`: passed. - Full UI suite: 6,529 tests passed across 626 files. - [CI run 35650385023](https://github.com/paperclipai/paperclip/actions/runs/35650385023): all 53 checks passed, including full workspace build, typecheck, unit/integration suites, and browser tests. The redundant local full-workspace test run was stopped after CI passed; it is not counted as a completed local pass. - Greptile: 5/5 on `608ee58c99`, with no review threads or unresolved comments. The branch is mergeable. - `pnpm -r typecheck` and `pnpm build` were attempted. Both stop at the Runner's Rust checks because this host has no `cargo`. The full workspace checks passed in CI. ## Risks Low risk. This changes UI presentation and response handling only. The server's exact-retry idempotency, authorization, execution ownership, and recovery gates remain in place. No schema changes or live task mutations. Existing documentation describes this retry action; the fix restores that behavior. ## Model Used OpenAI GPT-6 (Codex), with reasoning, repository tools, 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 (targeted checks; full-workspace limits described above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (existing behavior restored; no documentation change needed) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8813a50105
commit
df66219780
5 files changed
+160
-45
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { agentsApi } from "./agents";
|
||||
import { api } from "./client";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("agentsApi.retryFailedRun", () => {
|
||||
it.each(["failed", "timed_out", "cancelled", "interrupted"])(
|
||||
"reports a replayed %s retry instead of silently succeeding",
|
||||
async (status) => {
|
||||
const post = vi.spyOn(api, "post").mockResolvedValue({ id: "previous-retry", status });
|
||||
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
|
||||
.rejects.toThrow("The previous retry has already stopped");
|
||||
expect(post).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["queued", "running", "succeeded"])("accepts a %s successor without dispatching again", async (status) => {
|
||||
const post = vi.spyOn(api, "post").mockResolvedValue({ id: "successor", status });
|
||||
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
|
||||
.resolves.toEqual({ runId: "successor", issueId: null });
|
||||
expect(post).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves a durable chat retry that is waiting for dispatch", async () => {
|
||||
vi.spyOn(api, "post").mockResolvedValue({ actionId: "retry-action", status: "queued", runId: null, issueId: "issue-1" });
|
||||
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
|
||||
.resolves.toEqual({ runId: null, issueId: "issue-1" });
|
||||
});
|
||||
|
||||
it("reports a skipped wake", async () => {
|
||||
vi.spyOn(api, "post").mockResolvedValue({ skipped: true, message: "Task execution is paused." });
|
||||
await expect(agentsApi.retryFailedRun("agent-1", "original-failure", "company-1"))
|
||||
.rejects.toThrow("Task execution is paused.");
|
||||
});
|
||||
});
|
||||
+10
-1
@@ -259,7 +259,16 @@ export const agentsApi = {
|
||||
reason: "retry_failed_run",
|
||||
failedRunId,
|
||||
});
|
||||
if ("id" in result) return { runId: result.id, issueId: null };
|
||||
if ("id" in result) {
|
||||
// Exact retries are idempotent: a repeated click can return a successor
|
||||
// that has already stopped. It does not mean a new attempt was queued.
|
||||
if (["failed", "timed_out", "cancelled", "interrupted"].includes(result.status)) {
|
||||
throw new Error(
|
||||
"The previous retry has already stopped. Refresh the task and retry its latest failed run.",
|
||||
);
|
||||
}
|
||||
return { runId: result.id, issueId: null };
|
||||
}
|
||||
if ("actionId" in result) {
|
||||
if (result.status === "failed" || result.status === "cancelled") {
|
||||
throw new Error(
|
||||
|
||||
@@ -2175,10 +2175,74 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["active execution", "pending decision", "recovery hold"] as const)(
|
||||
"does not promise legacy Retry during %s and restores it when the gate clears",
|
||||
(gate) => {
|
||||
it.each(["failed", "timed_out"] as const)(
|
||||
"retries the latest legacy %s attempt even when it has a transcript and final comment",
|
||||
async (status) => {
|
||||
const onRetryFailedRun = vi.fn();
|
||||
transcriptState.transcriptByRun.set("latest-attempt", [{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:01:01.000Z",
|
||||
text: "Starting the requested revision.",
|
||||
}]);
|
||||
const run = {
|
||||
runtimeMode: "legacy" as const,
|
||||
adapterType: "claude_local",
|
||||
agentId: "agent-1",
|
||||
agentName: "Direct agent",
|
||||
startedAt: null,
|
||||
};
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[{
|
||||
id: "failure-comment",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
authorType: "agent",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
body: "The startup handshake timed out.",
|
||||
runId: "latest-attempt",
|
||||
createdAt: new Date("2026-08-25T18:01:02.000Z"),
|
||||
updatedAt: new Date("2026-08-25T18:01:02.000Z"),
|
||||
}]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="todo"
|
||||
onRetryFailedRun={onRetryFailedRun}
|
||||
linkedRuns={[
|
||||
{ ...run, runId: "original-failure", status: "failed",
|
||||
createdAt: "2026-08-25T18:00:00.000Z", startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: "2026-08-25T18:00:02.000Z" },
|
||||
{ ...run, runId: "latest-attempt", status, errorCode: "acpx_handshake_timeout",
|
||||
createdAt: "2026-08-25T18:01:00.000Z", startedAt: "2026-08-25T18:01:00.000Z",
|
||||
finishedAt: "2026-08-25T18:01:02.000Z",
|
||||
resultJson: { presentationDecision: { commentId: "failure-comment" } } },
|
||||
{ ...run, runId: "cancelled-automatic-retry", status: "cancelled",
|
||||
errorCode: "execution_reconciliation_required",
|
||||
createdAt: "2026-08-25T18:02:00.000Z", finishedAt: "2026-08-25T18:02:02.000Z" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("The startup handshake timed out.");
|
||||
const buttons = container.querySelectorAll<HTMLButtonElement>('[data-testid="task-chat-run-failed-try-again"]');
|
||||
expect(buttons).toHaveLength(1);
|
||||
flushSync(() => buttons[0]!.click());
|
||||
await Promise.resolve();
|
||||
expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("latest-attempt");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["active execution", "pending decision", "recovery hold"].flatMap(
|
||||
(gate) => [false, true].map((hasTranscript) => ({ gate, hasTranscript })),
|
||||
))(
|
||||
"does not promise legacy Retry during $gate (transcript: $hasTranscript) and restores it when the gate clears",
|
||||
({ gate, hasTranscript }) => {
|
||||
const onRetryFailedRun = vi.fn();
|
||||
if (hasTranscript) transcriptState.transcriptByRun.set("legacy-failed", [{
|
||||
kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: "Starting the task.",
|
||||
}]);
|
||||
const failedRun = {
|
||||
runId: "legacy-failed",
|
||||
runtimeMode: "legacy" as const,
|
||||
|
||||
@@ -1625,6 +1625,50 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
},
|
||||
});
|
||||
}
|
||||
// A failed run still needs its own retry target when it produced output
|
||||
// or a final comment. Otherwise the thread keeps retrying an older run.
|
||||
const sourceHasLegacyStop = !sourceIsPaperclipRunner && (
|
||||
source.status === "failed" || source.status === "timed_out" ||
|
||||
(source.status === "cancelled" && entries.length === 0)
|
||||
);
|
||||
if (sourceHasLegacyStop) {
|
||||
settledRunIds.add(source.id);
|
||||
const code = meta?.errorCode ?? "native_runner_process_exited";
|
||||
const retryDetail = meta?.scheduledRetryAt
|
||||
? "Retry scheduled automatically."
|
||||
: canRetryFailedRun
|
||||
? "You can retry this message now."
|
||||
: "Your message is preserved.";
|
||||
const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id);
|
||||
const detail = aiRequest
|
||||
? aiRequest.status === "pending"
|
||||
? "The selected AI account is unavailable. Fix it in the connection card."
|
||||
: "This run stopped because its AI account was unavailable."
|
||||
: source.status === "cancelled"
|
||||
? code === "execution_reconciliation_required"
|
||||
? "The previous execution must be checked before this task can continue. Your message is preserved. View the stopped run for details."
|
||||
: "Execution was stopped before returning an answer."
|
||||
: code === "provider_frame_too_large"
|
||||
? `Provider output exceeded the safe limit. ${retryDetail}`
|
||||
: code.startsWith("workspace_git_scan_")
|
||||
? `Workspace setup failed before the agent started. ${retryDetail}`
|
||||
: `The runner stopped before returning an answer (${code}). ${retryDetail}`;
|
||||
const id = `${source.id}:failure`;
|
||||
entriesWithFailures.push({
|
||||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||||
order: 3,
|
||||
id,
|
||||
item: {
|
||||
id,
|
||||
kind: "marker",
|
||||
variant: "interrupted",
|
||||
label: source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed",
|
||||
runId: source.status === "cancelled" ? undefined : source.id,
|
||||
tone: source.status === "cancelled" ? "neutral" : "error",
|
||||
detail,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
if (sourceIsPaperclipRunner && sourceYielded) {
|
||||
settledRunIds.add(source.id);
|
||||
@@ -1675,47 +1719,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
});
|
||||
settledRunIds.add(source.id);
|
||||
settledReplyRunIds.add(source.id);
|
||||
} else if (
|
||||
!sourceIsPaperclipRunner &&
|
||||
(source.status === "failed" || source.status === "timed_out" || source.status === "cancelled")
|
||||
) {
|
||||
settledRunIds.add(source.id);
|
||||
const code = meta?.errorCode ?? "native_runner_process_exited";
|
||||
const retryDetail = meta?.scheduledRetryAt
|
||||
? "Retry scheduled automatically."
|
||||
: canRetryFailedRun
|
||||
? "You can retry this message now."
|
||||
: "Your message is preserved.";
|
||||
const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id);
|
||||
const detail = aiRequest
|
||||
? aiRequest.status === "pending"
|
||||
? "The selected AI account is unavailable. Fix it in the connection card."
|
||||
: "This run stopped because its AI account was unavailable."
|
||||
: source.status === "cancelled"
|
||||
? code === "execution_reconciliation_required"
|
||||
? "The previous execution must be checked before this task can continue. Your message is preserved. View the stopped run for details."
|
||||
: "Execution was stopped before returning an answer."
|
||||
: code === "provider_frame_too_large"
|
||||
? `Provider output exceeded the safe limit. ${retryDetail}`
|
||||
: code.startsWith("workspace_git_scan_")
|
||||
? `Workspace setup failed before the agent started. ${retryDetail}`
|
||||
: `The runner stopped before returning an answer (${code}). ${retryDetail}`;
|
||||
const id = `${source.id}:failure`;
|
||||
entriesWithFailures.push({
|
||||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||||
order: 3,
|
||||
id,
|
||||
item: {
|
||||
id,
|
||||
kind: "marker",
|
||||
variant: "interrupted",
|
||||
label: source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed",
|
||||
runId: source.status === "cancelled" ? undefined : source.id,
|
||||
tone: source.status === "cancelled" ? "neutral" : "error",
|
||||
detail,
|
||||
},
|
||||
});
|
||||
} else if (!sourceHasNativeStop && !lastCommentIdByRun.has(source.id)) {
|
||||
} else if (!sourceHasNativeStop && !sourceHasLegacyStop && !lastCommentIdByRun.has(source.id)) {
|
||||
settledRunIds.add(source.id);
|
||||
const id = `${source.id}:terminal-notice`;
|
||||
entriesWithFailures.push({
|
||||
|
||||
@@ -1562,6 +1562,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
tone: "success",
|
||||
});
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.runs(issueId),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user