mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
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:
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);
|
||||
|
||||
@@ -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("");
|
||||
|
||||
Reference in new issue
Block a user