import { useLayoutEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { IssueRecoveryAction, WorkspaceSourceInspection } from "@paperclipai/shared"; import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared"; import { isTransientError } from "../api/errors"; import { useQueryView } from "./QueryView"; import { accessApi } from "../api/access"; import { issuesApi } from "../api/issues"; import { useDraftOwner, useDraftOwnerReader } from "../hooks/useDraftOwner"; import { canBoardConfirmOwnerStop, canBoardResolveRecoveryAction, isRetainedWorkspaceExportCause } from "../lib/recovery-reconcile"; import { queryKeys } from "../lib/queryKeys"; import { classifySendFailure, pendingSendKeyFor, readPendingSend, writePendingSend } from "../lib/pending-send"; import { asObject, cn } from "../lib/utils"; import { createUuid } from "../lib/uuid"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; /** Reports whether this surface owns the task's next recovery action. The task's * generic Retry stays hidden only while this surface offers its own control. */ function ReportRecoveryControl({ owns, onChange }: { owns: boolean; onChange?: (owns: boolean) => void }) { useLayoutEffect(() => { onChange?.(owns); return () => onChange?.(false); }, [owns, onChange]); return null; } const CONTROLLER_HOST_UNVERIFIED = "native_controller_host_unverified"; const OWNER_UNPROVABLE = "ownership_owner_unprovable"; /** What the stuck record is, by ownership kind, for the board's confirmation. */ const OWNER_GONE_RESOURCE: Record = { environment_run_lease_active: { what: "A sandbox lease from this task’s earlier run", releasing: "Releasing the sandbox lease…" }, legacy_controller_lease: { what: "This task’s earlier run", releasing: "Recovering the run…" }, environment_teardown_claim: { what: "A sandbox cleanup from this task’s earlier run", releasing: "Resuming the sandbox cleanup…" }, sandbox_creation_intent: { what: "A sandbox this task’s earlier run was creating", releasing: "Reconciling the sandbox…" }, workspace_runtime_control_operation: { what: "A workspace service change from this task’s earlier run", releasing: "Releasing the workspace…" }, native_workspace_finalize_attempt: { what: "A workspace save from this task’s earlier run", releasing: "Releasing the workspace save…" }, native_finalizer_lease: { what: "The result handling of this task’s earlier run", releasing: "Resuming the result handling…" }, native_cleanup_lease: { what: "The cleanup of this task’s earlier run", releasing: "Resuming the cleanup…" }, }; const OWNER_GONE_DEFAULT = { what: "A resource from this task’s earlier run", releasing: "Releasing the resource…" }; /** Causes whose one action is the board's previous-server stop confirmation; * the generic recovery card must not offer other resolutions for them. */ export function isPreviousServerStopCause(cause: string | null | undefined) { return cause === "native_workspace_finalization_owner_unverified" || cause === CONTROLLER_HOST_UNVERIFIED || cause === OWNER_UNPROVABLE; } /** Requests preservation; the existing worker owns stop, copyback and verification. */ export function WorkspaceExportRecovery({ action, className, issueId, companyId, showStatus = true, onRecoveryControlChange }: { className?: string; action: IssueRecoveryAction | null; issueId?: string; companyId?: string; showStatus?: boolean; onRecoveryControlChange?: (owns: boolean) => void; }) { // 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 ; } // An ephemeral server cannot judge a pre-upgrade controller row by PID. if (action?.cause === CONTROLLER_HOST_UNVERIFIED && issueId && companyId && isControllerStopRequired(action, issueId, companyId)) { return <> issuesApi.confirmControllerStopped(issueId, { actionId: action.id, runId: action.evidence.runId as string, stopEvidence })} copy={{ waiting: "This run’s previous server was recorded before Paperclip stored which host it ran on, so Paperclip can’t verify that it stopped. The run’s saved state is kept.", question: "How did you confirm that the previous server stopped?", placeholder: "For example: the old server container was replaced and is no longer running.", resuming: "Resuming the run…" }} />; } // A resource held by a server recorded before Paperclip stored which server held it. if (action?.cause === OWNER_UNPROVABLE && issueId && companyId && isOwnerGoneRequired(action, issueId, companyId)) { const resource = OWNER_GONE_RESOURCE[String(action.evidence.kind)] ?? OWNER_GONE_DEFAULT; return <> {/* Keyed by the record version: a request refreshed to a newer version resets an open form, so the board never confirms a version it was not shown. */} issuesApi.confirmOwnerGone(issueId, { actionId: action.id, recordId: action.evidence.recordId as string, stopEvidence, // The version shown here: a request refreshed since must be confirmed again. ...(typeof action.evidence.recordUpdatedAt === "string" ? { recordVersion: action.evidence.recordUpdatedAt } : {}) })} copy={{ waiting: `${resource.what} was recorded before Paperclip stored which server held it, so Paperclip can’t verify that the server stopped. It stays reserved until then.`, question: "How did you confirm that the previous server stopped?", placeholder: "For example: the old server container was replaced and is no longer running.", resuming: resource.releasing }} />; } const ownerRecovery = action?.cause === "native_workspace_finalization_owner_unverified"; // The task's interruption names this wait; the confirmation is its one action. if (ownerRecovery && issueId && companyId && isOwnerStopRequired(action, issueId, companyId)) { return <> ; } if (!showStatus) return ; if (!action || (!ownerRecovery && !isNativeWorkspaceExportRepairCause(action.cause)) || action.ownerType !== "board" || !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string") return ; const queued = !!action.evidence.workspaceOwnerStop || action.wakePolicy?.kind === "resume_native_run"; const automatic = queued || (ownerRecovery && asObject(action.evidence.workspaceOwnerRecovery).state === "resuming"); return <>

{automatic ? "Restoring saved work…" : "Saved work is not recovered yet. The agent’s result is saved."}

; } function isRetainedExport(action: IssueRecoveryAction, issueId: string, companyId: string) { return action.companyId === companyId && action.sourceIssueId === issueId && action.kind === "active_run_watchdog" && ["active", "escalated"].includes(action.status) && ["board", "system"].includes(action.ownerType) && isRetainedWorkspaceExportCause(action.cause) && typeof action.evidence.runId === "string" && action.evidence.runId.length > 0; } function isControllerStopRequired(action: IssueRecoveryAction, issueId: string, companyId: string) { return action.companyId === companyId && action.sourceIssueId === issueId && action.ownerType === "board" && ["active", "escalated"].includes(action.status) && typeof action.evidence.runId === "string" && !action.evidence.controllerStop; } function isOwnerGoneRequired(action: IssueRecoveryAction, issueId: string, companyId: string) { return action.companyId === companyId && action.sourceIssueId === issueId && action.ownerType === "board" && ["active", "escalated"].includes(action.status) && typeof action.evidence.recordId === "string" && !action.evidence.ownerGone; } function isOwnerStopRequired(action: IssueRecoveryAction, issueId: string, companyId: string) { return action.companyId === companyId && action.sourceIssueId === issueId && action.ownerType === "board" && ["active", "escalated"].includes(action.status) && typeof action.evidence.runId === "string" && typeof asObject(action.evidence.owner).token === "string" && !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, ownerToken, issueId, companyId, className, confirmStop, copy }: { action: IssueRecoveryAction; issueId: string; companyId: string; className?: string; /** Saved-work copyback owner; used when no custom confirmation is given. */ ownerToken?: string; confirmStop?: (stopEvidence: string) => Promise; copy?: { waiting: string; question: string; placeholder: string; resuming: string }; }) { const runId = action.evidence.runId as string; const client = useQueryClient(); const [open, setOpen] = useState(false); const [evidence, setEvidence] = useState(""); const access = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess() }); const accessView = useQueryView(access); const canConfirm = (accessView.kind === "ready" || accessView.kind === "stale") && canBoardConfirmOwnerStop(companyId, access.data); const confirm = useMutation({ retry: false, // query-error-ok: an explicit board confirmation; the error is shown below. mutationFn: () => confirmStop ? confirmStop(evidence.trim()) : issuesApi.resumeWorkspaceFinalization(issueId, { actionId: action.id, runId, ownerToken: ownerToken!, controllerAndCopybackStopped: true, stopEvidence: evidence.trim() }), onSuccess: () => { setOpen(false); void client.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }); void client.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId) }); void client.invalidateQueries({ queryKey: queryKeys.runDetail(runId) }); } }); const ready = evidence.trim().length >= 20; return

{confirm.isSuccess ? copy?.resuming ?? "Restoring saved work…" : copy?.waiting ?? "The previous server stopped while it was saving this task’s files, and Paperclip can’t verify that it stopped. The agent’s result is saved."}

{confirm.isSuccess ? null : !canConfirm ?

{accessView.kind === "loading" || accessView.kind === "reconnecting" ? "Checking access…" : "Paperclip is waiting to confirm that the previous server stopped."}

: !open ? :
{ event.preventDefault(); if (ready && !confirm.isPending) confirm.mutate(); }}>