fix(recovery): show progress once an explicit native retry starts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 21:38:40 -05:00
1 parent 2321ddd573
commit 9d4cee911d
6 files changed
+33 -6

No files matched your search

+4 -1
View File
@@ -1476,4 +1476,7 @@ not require or synthesize a legacy `scheduledRetryReason`.
The card describes recovery of the existing run. It does not describe a fresh
owner disposition turn. This also covers native bootstrap/session recovery,
which shares the same resume policy. Inbox rows, source cards, and blocker chips
use the same activity projection and keep board-owned repairs actionable.
use the same activity projection. Board-owned repairs stay actionable until a
verified native retry actually starts. Once an explicit board retry is running,
its live activity takes precedence over the prior owner and exhausted budget.
Resolved and cancelled actions remain resolved.
@@ -150,6 +150,18 @@ describe("IssueRecoveryActionCard", () => {
expect(node.querySelector('[data-testid="recovery-retry-progress"]')?.getAttribute("data-recovery-lane")).toBe("native_run");
});
it("describes actual export progress after the board retries an exhausted repair", () => {
const node = render(<IssueRecoveryActionCard action={buildAction({
kind: "active_run_watchdog", cause: "native_finalization_invalid", ownerType: "board", status: "escalated", attemptCount: 3,
wakePolicy: { kind: "resume_native_run", runId: "native-run" },
nativeRunActivity: { runId: "native-run", status: "running", workspaceOperationId: "export-operation" },
})} />);
expect(node.querySelector("section")?.getAttribute("data-recovery-state")).toBe("in_progress");
expect(node.textContent).toContain("Paperclip is recovering the existing run");
expect(node.querySelector('[data-testid="recovery-recovery-owner"]')?.textContent).toContain("Paperclip");
expect(node.textContent).not.toContain("Automatic retries are finished");
});
it("renders state and kind attributes with owner names and the recorded next action", () => {
const node = render(
<IssueRecoveryActionCard
@@ -904,8 +904,8 @@ function formatTimeAbsolute(value: string | Date | null | undefined): string | n
*/
function lineageHeadline(lineage: RecoveryRetryLineage): string {
if (lineage.lane === "native_run") {
if (lineage.exhausted) return "Paperclip could not recover the existing run within its retry budget. Review the recorded failure before retrying. The task stays with its original owner.";
if (lineage.liveRunId) return "Paperclip is recovering the existing run. The task stays with its original owner.";
if (lineage.exhausted) return "Paperclip could not recover the existing run within its retry budget. Review the recorded failure before retrying. The task stays with its original owner.";
if (lineage.retryExpired) return "The retry for the existing run came due and did not start. Review the recorded failure and retry when ready. The task stays with its original owner.";
if (lineage.nextRetryAt) return "Paperclip has scheduled another attempt to resume the existing run. The task stays with its original owner.";
return "The existing run still needs recovery. Review the recorded failure before retrying. The task stays with its original owner.";
@@ -1028,7 +1028,7 @@ export function IssueRecoveryActionCard({
// the budget ran out or the scheduled attempt simply never fired.
const wakeSummary = lineage?.retryExpired
? "The scheduled retry did not run — a retry or a decision is needed"
: lineage?.exhausted && lineage.lane !== "board"
: lineage?.exhausted && !lineage.liveRunId && lineage.lane !== "board"
? "Automatic retries are finished — a decision is needed"
: readWakePolicySummary(action);
const evidenceSummary = pickEvidenceSummary(action);
@@ -1184,7 +1184,7 @@ export function IssueRecoveryActionCard({
className="inline-flex flex-wrap items-center gap-1.5"
data-testid="recovery-recovery-owner"
>
{lineage.lane === "native_run" && action.ownerType !== "board" ? (
{lineage.lane === "native_run" && (action.ownerType !== "board" || Boolean(lineage.liveRunId)) ? (
<>
<span className="font-medium">Paperclip</span>
<span className="text-muted-foreground">recovers the existing run</span>
+7
View File
@@ -171,6 +171,13 @@ describe("deriveRecoveryDisplayState", () => {
},
);
it.each(["active", "escalated"] as const)("shows a board-admitted export as progress despite prior %s repair ownership", (status) => {
expect(deriveRecoveryDisplayState({
...action, status, ownerType: "board", attemptCount: 3,
nativeRunActivity: { runId: "finalizing-run", status: "running", workspaceOperationId: "export-operation" },
}, { now })).toBe("in_progress");
});
it("keeps exhausted retries and board-owned failures actionable", () => {
const wakePolicy = { ...action.wakePolicy, notBefore: "2026-09-27T02:01:00.000Z" };
expect(deriveRecoveryDisplayState({ ...action, wakePolicy, attemptCount: 3 }, { now })).toBe("needed");
+5 -1
View File
@@ -71,7 +71,6 @@ export function deriveRecoveryDisplayState(
context?: RecoveryLivenessContext,
): RecoveryDisplayState {
if (action.status === "resolved") return "resolved";
if (action.status === "escalated") return "escalated";
if (action.status === "cancelled") return "resolved";
// A bounded retry lineage still holding a durable path is work the server will do on its
// own. Shouting "recovery needed" over it would ask a human to fix something nobody has to
@@ -86,6 +85,11 @@ export function deriveRecoveryDisplayState(
timeoutAt: action.timeoutAt,
nativeRunActivity: action.nativeRunActivity,
}, context);
// An explicit board retry may retain its old owner/budget while the exact
// native run is already making progress. Actual activity wins over that
// historical repair state, but a merely scheduled board retry does not.
if (lineage?.lane === "native_run" && lineage.liveRunId) return "in_progress";
if (action.status === "escalated") return "escalated";
if (action.kind === "active_run_watchdog") {
// Native finalization shares the watchdog kind, but resumes a failed
// coordinator rather than observing a live agent turn. Preserve board
+2 -1
View File
@@ -198,7 +198,8 @@ export function readRecoveryRetryLineage(
sourceAttempt: nativeResume ? null : lane === "source_owner" ? attempt : evidenceSourceAttempt,
sourceMaxAttempts: nativeResume ? null : lane === "source_owner" ? maxAttempts : evidenceSourceMaxAttempts,
hasDurablePath:
!exhausted && (liveRunId !== null || (nextRetryAt !== null && !retryExpired)),
(nativeResume && liveRunId !== null) ||
(!exhausted && (liveRunId !== null || (nextRetryAt !== null && !retryExpired))),
};
}