From 9d4cee911d5a56079f7eb71a396c5055ab6ec8a1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 27 Sep 2026 21:37:22 -0500 Subject: [PATCH] fix(recovery): show progress once an explicit native retry starts Co-Authored-By: Paperclip --- doc/execution-semantics.md | 5 ++++- ui/src/components/IssueRecoveryActionCard.test.tsx | 12 ++++++++++++ ui/src/components/IssueRecoveryActionCard.tsx | 6 +++--- ui/src/lib/recovery-display.test.ts | 7 +++++++ ui/src/lib/recovery-display.ts | 6 +++++- ui/src/lib/recovery-lineage.ts | 3 ++- 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 19e215df864..50a386a359d 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -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. diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 077b1707b8e..75e4b3330cf 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -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(); + 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( - {lineage.lane === "native_run" && action.ownerType !== "board" ? ( + {lineage.lane === "native_run" && (action.ownerType !== "board" || Boolean(lineage.liveRunId)) ? ( <> Paperclip recovers the existing run diff --git a/ui/src/lib/recovery-display.test.ts b/ui/src/lib/recovery-display.test.ts index 5c71fed94e2..8c6275febf6 100644 --- a/ui/src/lib/recovery-display.test.ts +++ b/ui/src/lib/recovery-display.test.ts @@ -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"); diff --git a/ui/src/lib/recovery-display.ts b/ui/src/lib/recovery-display.ts index dff2fc5d67b..83458672d9c 100644 --- a/ui/src/lib/recovery-display.ts +++ b/ui/src/lib/recovery-display.ts @@ -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 diff --git a/ui/src/lib/recovery-lineage.ts b/ui/src/lib/recovery-lineage.ts index c172ef4db08..1bd8e61edf3 100644 --- a/ui/src/lib/recovery-lineage.ts +++ b/ui/src/lib/recovery-lineage.ts @@ -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))), }; }