fix(ui): offer the owner-stop confirmation for retained saved-work recovery (#19742)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run saves the agent's files after its turn (copyback). One
controller owns copyback and records an owner receipt on the run
> - #19721 made retained saved-work recovery stop spending attempts when
a restart killed that owner. It records the receipt as
`workspaceOwnerRecovery.state = "operator_required"`, and a waiting
Retry reports `workspace_owner_stop_required`
> - #19717 added one task control, **Confirm the previous server
stopped**, for the direct finalization hold. That control did not cover
the retained hold, so the task told the board to confirm but offered
nothing to click
> - This pull request shows the same control for the retained hold, with
the recorded receipt token
> - The benefit is that every copyback-owner hold has exactly one
visible action, and the waiting Retry then continues by itself

## Linked Issues or Issue Description

Refs #19721 (retained hold, server side), #19717 (the confirmation
control).

**What happened?**
The retained recovery action named the stopped copyback owner, and the
task's Retry said "Retry is waiting for a board member to confirm that
the previous server stopped copying files". No control called the
confirmation route for this state.

**Steps to reproduce**
1. Retained saved-work recovery owns copyback for a finished run.
2. A restart kills the controller while it owns copyback, on another
host or without automatic stop proof.
3. Open the task.

**Expected behavior**
The task shows one **Confirm the previous server stopped** action to an
owner or administrator. It sends the recorded receipt token to
`resume-workspace-finalization`.

**Actual behavior**
The task shows only the retained file-recovery controls. The token is in
`evidence.workspaceOwnerRecovery.owner`, which the #19717 control did
not read.

## What Changed

- `WorkspaceExportRecovery`: when a retained export action has
`workspaceOwnerRecovery.state = "operator_required"` and no recorded
confirmation, render the existing confirmation control with that
receipt's token, in place of the retained file-recovery controls. After
the confirmation (state `resuming`), the ordinary retained controls
return.
- The confirmation control now takes the owner token as a prop, so the
direct hold (`evidence.owner`) and the retained hold
(`evidence.workspaceOwnerRecovery.owner`) share it.
- Docs: one sentence in `doc/native-workspace-finalization-recovery.md`.
- There are no server changes. The route already dispatches the retained
path (#19721).

## Verification

- Red then green: the new test "offers the same action for retained
saved-work recovery held by a stopped copyback owner" fails against
master's component and passes with this change. It checks that the
request body carries the retained receipt token, that the host name is
not shown, and that the retained controls return after `resuming`.
- `cd ui && npx vitest run src/components/WorkspaceExportRecovery` (35
passed).
- `pnpm check:token-gates` clean. UI typecheck clean.

## Risks

- Low. The change is UI only. The server rechecks the receipt, the hold
state and `runtime:confirm_owner_stop`.
- While the hold is active, the "Recover saved files" control is hidden.
It cannot release the receipt, so hiding it removes a futile action.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, test runs).

## 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 <[email protected]>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-06 23:48:43 -06:00
1 parent 131b566ca4
commit 4dc5281f51
3 files changed
+50 -5

No files matched your search

@@ -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.
@@ -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);
+20 -4
View File
@@ -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 <PreviousServerStopConfirmation key={`${action.id}:${String(action.evidence.runId)}:${retainedOwnerToken}`}
action={action} ownerToken={retainedOwnerToken} issueId={issueId} companyId={companyId} className={className} />;
}
if (action && issueId && companyId && isRetainedExport(action, issueId, companyId)) {
return <RetainedFilesRecovery key={`${companyId}:${issueId}:${action.id}:${action.evidence.runId}`}
action={action} issueId={issueId} companyId={companyId} className={className} />;
@@ -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 <PreviousServerStopConfirmation key={`${action.id}:${String(action.evidence.runId)}`}
action={action} issueId={issueId} companyId={companyId} className={className} />;
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("");