diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md index 06caa8ce879..3a11fd1464d 100644 --- a/doc/native-workspace-finalization-recovery.md +++ b/doc/native-workspace-finalization-recovery.md @@ -632,7 +632,10 @@ It records the exact receipt on the retained recovery action as the bounded recovery window. A task Retry waiting on that saved result reports `workspace_owner_stop_required` and does not offer another Retry, because another click cannot release the receipt. The board confirms the stop through the same -`resume-workspace-finalization` route. The confirmation must name the receipt that +`resume-workspace-finalization` route. The task chat shows the same single +**Confirm the previous server stopped** control in place of the retained file +recovery controls while this hold is active, with the recorded receipt token, to +roles that hold `runtime:confirm_owner_stop`. The confirmation must name the receipt that is still on the run. It clears only that receipt and returns the existing recovery to the worker (a spent budget gets one new generation). It accepts no result and starts no agent turn. The waiting Retry then continues through its normal path. diff --git a/ui/src/components/WorkspaceExportRecovery.control.test.tsx b/ui/src/components/WorkspaceExportRecovery.control.test.tsx index 2ea8ba14431..0a10fc87c7f 100644 --- a/ui/src/components/WorkspaceExportRecovery.control.test.tsx +++ b/ui/src/components/WorkspaceExportRecovery.control.test.tsx @@ -316,6 +316,32 @@ describe("governed retained-file recovery", () => { expect(container.textContent).toContain("Restoring saved work…"); }); + it("offers the same action for retained saved-work recovery held by a stopped copyback owner", async () => { + api.resume.mockResolvedValue({ runId: "run-1", resultId: null, status: "queued" }); + client.setQueryData(queryKeys.access.currentBoardAccess, { userId: "board-1", source: "session", isInstanceAdmin: false, + companyIds: ["company-1"], memberships: [{ companyId: "company-1", membershipRole: "admin", status: "active" }] }); + const held = { ...action, evidence: { ...action.evidence, + workspaceOwnerRecovery: { state: "operator_required", owner: { token: "retained-token", hostname: "secret-host", pid: 9 } } } } as IssueRecoveryAction; + await mount(held); + expect(button("Recover saved files")).toBeUndefined(); + expect(container.textContent).not.toContain("secret-host"); + await click("Confirm the previous server stopped"); + const textarea = container.querySelector("textarea")!; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; + setter.call(textarea, "The old server process exited and was not restarted."); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + await click("Confirm and resume"); + expect(api.resume).toHaveBeenCalledExactlyOnceWith("issue-1", { actionId: "action-1", runId: "run-1", + ownerToken: "retained-token", controllerAndCopybackStopped: true, + stopEvidence: "The old server process exited and was not restarted." }); + // Once confirmed or resuming, the ordinary retained recovery controls return. + await mount({ ...held, evidence: { ...held.evidence, workspaceOwnerRecovery: { state: "resuming" } } } as IssueRecoveryAction); + expect(button("Confirm the previous server stopped")).toBeUndefined(); + expect(container.querySelector('[data-testid="retained-file-recovery"]')).not.toBeNull(); + }); + it("shows status only, without the action, once automatic recovery is resuming or for non-administrators", async () => { // The default fixture is an operator: it may resolve other recovery but not attest a controller stop. await mount({ ...ownerAction, evidence: { ...ownerAction.evidence, workspaceOwnerRecovery: { state: "resuming" } } } as IssueRecoveryAction); diff --git a/ui/src/components/WorkspaceExportRecovery.tsx b/ui/src/components/WorkspaceExportRecovery.tsx index 0f06520bb85..bf48de41db2 100644 --- a/ui/src/components/WorkspaceExportRecovery.tsx +++ b/ui/src/components/WorkspaceExportRecovery.tsx @@ -19,6 +19,14 @@ import { Textarea } from "./ui/textarea"; export function WorkspaceExportRecovery({ action, className, issueId, companyId, showStatus = true }: { className?: string; action: IssueRecoveryAction | null; issueId?: string; companyId?: string; showStatus?: boolean; }) { + // A restart killed the copyback owner of retained saved-work recovery. Recovery + // and any accepted Retry wait on its receipt; the confirmation is the one action. + const retainedOwnerToken = action && issueId && companyId && isRetainedExport(action, issueId, companyId) + ? retainedOwnerStopToken(action) : null; + if (action && issueId && companyId && retainedOwnerToken) { + return ; + } if (action && issueId && companyId && isRetainedExport(action, issueId, companyId)) { return ; @@ -27,7 +35,8 @@ export function WorkspaceExportRecovery({ action, className, issueId, companyId, // The task's interruption names this wait; the confirmation is its one action. if (ownerRecovery && issueId && companyId && isOwnerStopRequired(action, issueId, companyId)) { return ; + action={action} ownerToken={asObject(action.evidence.owner).token as string} + issueId={issueId} companyId={companyId} className={className} />; } if (!showStatus) return null; if (!action || (!ownerRecovery && !isNativeWorkspaceExportRepairCause(action.cause)) || action.ownerType !== "board" @@ -54,13 +63,20 @@ function isOwnerStopRequired(action: IssueRecoveryAction, issueId: string, compa && !action.evidence.workspaceOwnerStop && asObject(action.evidence.workspaceOwnerRecovery).state !== "resuming"; } +/** The exact receipt #19721 records when retained recovery waits on a stopped copyback owner. */ +function retainedOwnerStopToken(action: IssueRecoveryAction): string | null { + const hold = asObject(action.evidence.workspaceOwnerRecovery); + const token = asObject(hold.owner).token; + return hold.state === "operator_required" && !action.evidence.workspaceOwnerStop + && typeof action.evidence.runId === "string" && typeof token === "string" && token.length > 0 ? token : null; +} + /** A previous server that can no longer report cannot confirm its own stop. * The board confirms it once; the saved result then finishes without another agent turn. */ -function PreviousServerStopConfirmation({ action, issueId, companyId, className }: { - action: IssueRecoveryAction; issueId: string; companyId: string; className?: string; +function PreviousServerStopConfirmation({ action, ownerToken, issueId, companyId, className }: { + action: IssueRecoveryAction; ownerToken: string; issueId: string; companyId: string; className?: string; }) { const runId = action.evidence.runId as string; - const ownerToken = asObject(action.evidence.owner).token as string; const client = useQueryClient(); const [open, setOpen] = useState(false); const [evidence, setEvidence] = useState("");