mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +01:00
## Thinking Path > - Paperclip manages agents and their work. > - Recovery must preserve execution ownership and accepted results. > - A saved PID proves nothing outside its recorded process namespace. > - Uncertain unfinished work must stay held until its original owners stop. > - Operators need a source-bound containment action and a separate ordinary Retry. > - Accepted results must use the existing finalizer without another provider turn. > - This PR keeps those paths safe, recoverable, and quiet between real obligations. ## Linked Issues or Issue Description Refs: #19713 Refs: #19591 Recovery could use a local PID to release or replace a controller from another process namespace. Connected-chat Retry also rejected successful containment on a task in `todo`. The new containment action used a broader permission than the existing owner-stop action. A later review found that result-less guards also rejected the existing accepted-result confirmation action. ## What Changed - Record and verify the controller's kernel/PID namespace. Keep unknown and foreign unfinished ownership held. Preserve independent durable stop evidence and the existing remote lease fences. - Use the shared accepted-result ownership reconciler. Bind settlement to the observed lease token, generation, boot, PID, namespace and process birth. Retain finalizer, cleanup, current-controller, live-owner and remote exclusions. - Share the remote-owner reader across recovery consumers. Scope runner leases to the same company, task and run. Do not make local PID probes for remote accepted-result ownership. - Gate original-host containment on `runtime:confirm_owner_stop`. Bind it to the current task, source, assignee, original expiry, lease and Stop state. Audit separate controller/provider containment and reviewed action outcomes. - Keep containment separate from ordinary Retry. Use one canonical committed receipt and the normal admission, budget, workspace and lineage checks. Preserve the recorded external-action outcomes. - Keep the controller-only confirmation actionable for both result-less work and accepted results. Result-less work gets its next source-bound attention in the same transaction. Accepted results keep their original result and use normal shared settlement/finalization. - Keep identity inspection quiet. Preserve the original foreign-owner expiry and use the existing scoped finalizer in accepted-result recovery controls. - Update the recovery card, validators, execution projection, docs and access maps. Reuse existing canonical columns; add no migration or ownership backfill. ## Verification Current publication head: `71ea76b568de09d6313d8992e803131d9d5c54f3`. **The current fix has a causal red/green check.** On the test-only revision, both foreign-local stopped/fenced controller cases failed because recovery probed saved runner, process-group and provider PIDs in this host's namespace. This measured the wrong probes; it did not run duplicate providers. After the fix, the same two cases passed every no-probe, no-claim, no-controller-stamp, no-stop-receipt and unchanged-authority assertion. Three existing remote preparation, grace and fenced-controller progress cases also passed. Total: **5 passed, 0 failed;170 filtered rows excluded**. All three normal affected App TypeScript programs passed on this head (660,681 and428 roots). The fix separates a controller wait from execution stop proof. Independent live/grace evidence still schedules recovery. A stopped/fenced foreign controller can pass this namespace guard only for remote execution, which keeps its later checkpoint and never-launched checks. Foreign local execution stays held before runner or provider PID inspection. Earlier source-scoped results remain evidence, not fresh executions on the current head: - On `c86dd741`, the complete failed-owner file passed **25/25**, with no skips or filters. Empty scans wrote no controller stamps. Foreign ownership kept its original expiry. Normal returned-claim settlement and attention at the exact deadline passed. - On `c86dd741`, **15 selected accepted-result/restart controls passed**. The81 filtered rows are excluded. Both completion cases used the existing scoped finalizer and kept their accepted result without another provider turn. - Trusted CI at synthetic merge `50d072a5` (actual master `1b7fe334` plus `c86dd741`) passed the complete **17-case maintenance-server file**. This includes the original90-real-second quiet observation and two qualified hourly windows. The quiet case took118,918ms. The four jobs that failed on the earlier publication all passed on that synthetic merge. - On `6243b32c`, the complete ownership-report file passed **29/29**. Native NULL namespace stayed unprovable and generic compatibility remained. Three selected recovery controls passed;170 filtered rows are excluded. - On `f81f2c3c`,356 business cases passed: whole ownership-route221, recoveryUI70, owner-proof38 and controller-confirmation8; selected accepted-result authority18; and one real connected-chat containment, separate authorized Retry, useful Native result and downstream reply case. The2,024 filtered rows are excluded. A separate earlier accepted-result causal case kept the original result and completed after restart without replay. Normal Shared exports, App programs, UI typecheck, token gates and map checks passed on their recorded revisions. Unchanged DB, adapter, Eval and Runner inputs/outputs have explicit carry proofs. The clean normal merge with fetched master `8fe524f7` preserves all31 exclusive owned paths and14,827 unrelated master entries. The nine overlapping paths were reviewed, including the upstream unidentified-run stop action. Fresh CI must validate that actual merge composition. PG cases used existing external PG17, fresh owned databases, pools at most4, one worker and no SQL observer during cases. Registered clients and recorded process groups closed. Independent final SQL audits found zero campaign sessions before normal database cleanup. Original failing fixtures remain retained. Original failures are preserved. One new test first failed compilation due to a missing getter import; an import-only correction passed its full owning programs before the causal red/green check. A local quiet attempt stopped in the normal prerequisite hook before any business case and its collector rejected two normal build markers. The later17-case CI pass establishes quiet recovery; it does not turn that failed local collector into a pass. An earlier useful case passed before its private cleanup collector failed on an owned read-only directory; normal cleanup completed after all owners closed. Earlier runner-shutdown cancellation and a separate private whole-suite failure remain unexplained. Current-head checks are complete:57 success,1 neutral and3 skipped, including the successful aggregate CI gate. The legacy status passed. Greptile is5/5 and Ponytail gives an explicit5/5 Ship verdict for this head. All seven review threads are resolved. Full recursive typecheck, the full test suite and release build were not repeated locally. No result here proves deployed suspension, a live provider or termination on another host. ## Risks - Unknown physical ownership can require an owner/admin action. A false containment statement can be unsafe. - New work, reassignment, Stop, renewed authority, accepted results or pending cleanup can invalidate an action. The operator must refresh. - Controller confirmation does not prove provider/tool containment. Completed external actions must not be replayed. - Generic legacy and child-process compatibility remains separate from the strict Native controller policy. - Existing saved state and canonical schema are preserved. Permanent safety refusals remain held with their normal named recovery path. ## Model Used OpenAI `gpt-6.1-sol` agents implemented and tested the changes with reasoning, repository tools and code execution. A GPT-6-based Codex managing agent reviewed and integrated them. The host does not expose the manager's exact build or the context-window sizes. ## 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 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
1571 lines
66 KiB
TypeScript
1571 lines
66 KiB
TypeScript
import { useMutation } from "@tanstack/react-query";
|
||
import { issuesApi } from "../api/issues";
|
||
import { describeError } from "../api/errors";
|
||
import { Checkbox } from "./ui/checkbox";
|
||
import { NativeSelect } from "./ui/select";
|
||
import type { Issue } from "@paperclipai/shared";
|
||
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
|
||
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
|
||
import { requiresExecutionReconciliation } from "@paperclipai/shared";
|
||
import { useMemo, useState } from "react";
|
||
import type {
|
||
Agent,
|
||
GitWorktreeBranchAncestryVerdict,
|
||
IssueRecoveryAction,
|
||
IssueRecoveryActionKind,
|
||
IssueRecoveryActionOutcome,
|
||
IssueRecoveryActionStatus,
|
||
IssueScheduledRetry,
|
||
} from "@paperclipai/shared";
|
||
import {
|
||
Eye,
|
||
GitBranch,
|
||
GitBranchPlus,
|
||
Loader2,
|
||
Lock,
|
||
OctagonAlert,
|
||
RefreshCw,
|
||
Sparkles,
|
||
TriangleAlert,
|
||
Wrench,
|
||
} from "lucide-react";
|
||
import { Link } from "@/lib/router";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { agentUrl } from "@/lib/utils";
|
||
import { cn } from "@/lib/utils";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
deriveRecoveryDisplayState,
|
||
type RecoveryDisplayState,
|
||
} from "@/lib/recovery-display";
|
||
import {
|
||
formatRecoveryAttemptLabel,
|
||
formatRecoveryRetryOffset,
|
||
readRecoveryRetryLineage,
|
||
type RecoveryRetryLineage,
|
||
} from "@/lib/recovery-lineage";
|
||
|
||
export type RecoveryCardCardState = RecoveryDisplayState;
|
||
export const deriveRecoveryCardState = deriveRecoveryDisplayState;
|
||
|
||
export type RecoveryResolveOutcome =
|
||
| "todo"
|
||
| "done"
|
||
| "in_review"
|
||
| "false_positive_done"
|
||
| "false_positive_in_review";
|
||
|
||
/**
|
||
* Payload for the "Re-issue on isolated workspace" action (workspace_validation only).
|
||
* The caller composes an isolated-workspace re-issue whose git worktree bases off `baseRef`
|
||
* — the live (checked-out) branch that diverged, or its HEAD sha when the branch is detached.
|
||
*/
|
||
export interface RecoveryReissueRequest {
|
||
baseRef: string;
|
||
liveBranch: string | null;
|
||
liveHeadSha: string | null;
|
||
expectedBranch: string | null;
|
||
}
|
||
|
||
export interface IssueRecoveryActionCardProps {
|
||
action: IssueRecoveryAction;
|
||
agentMap?: ReadonlyMap<string, Agent>;
|
||
/**
|
||
* The source issue's scheduled retry. It is the only signal that can confirm the run the
|
||
* wake policy parked is genuinely in flight, which is what separates a retry the scheduler
|
||
* is running from one whose due time quietly passed.
|
||
*/
|
||
scheduledRetry?: IssueScheduledRetry | null;
|
||
/** Preferred state hint (e.g. observe_only when watchdog tone is requested). Falls back to derived state. */
|
||
forcedState?: RecoveryCardCardState;
|
||
/** Optional click handler for resolve menu actions. If omitted, the buttons are not rendered. */
|
||
onResolve?: (outcome: RecoveryResolveOutcome) => void;
|
||
/**
|
||
* Optional handler for the workspace_validation "Re-issue on isolated workspace" action.
|
||
* Rendered only for a git-worktree branch-incoherence divergence with a resolvable live ref.
|
||
* If omitted, the re-issue button is not shown.
|
||
*/
|
||
onReissueIsolated?: (request: RecoveryReissueRequest) => void;
|
||
/** Whether an isolated re-issue is currently in flight (disables the action + shows a spinner). */
|
||
reissuePending?: boolean;
|
||
/**
|
||
* Handler for action 1 — "Reconcile forward & continue" (workspace_validation only). Rendered
|
||
* only for an ancestry-proven (`ancestor`) git-worktree divergence; the caller invokes the S4
|
||
* reconcile op in `forward` mode, which re-verifies ancestry server-side (the client hint is
|
||
* never trusted). If omitted, the button is not shown.
|
||
*/
|
||
onReconcileForward?: () => void;
|
||
/**
|
||
* Handler for action 2 — the audited break-glass override (workspace_validation only). Receives
|
||
* the operator's required, non-empty reason and invokes the S4 reconcile op in `override` mode.
|
||
* Rendered only when `canBreakGlass` is true AND this handler is provided; the server independently
|
||
* rejects agent actors and re-checks runtime-manage permission, so UI hiding is defense-in-depth.
|
||
*/
|
||
onBreakGlassOverride?: (reason: string) => void;
|
||
/**
|
||
* Whether the viewer may run the permission-gated break-glass override. When false, action 2 is
|
||
* not rendered at all — a non-permitted user never sees the "reconcile anyway" affordance.
|
||
*/
|
||
canBreakGlass?: boolean;
|
||
/**
|
||
* Handler for the lossless repair — "Repair workspace — quarantine changes & restore branch"
|
||
* (workspace_validation only). Rendered only for a *dirty* divergence; the caller invokes the S4
|
||
* reconcile op in `quarantine_restore` mode, which quarantines the dirty worktree onto a rescue
|
||
* branch and restores the recorded branch. If omitted, the repair action is not shown.
|
||
*/
|
||
onQuarantineRestore?: () => void;
|
||
/** Whether a quarantine-restore repair is currently in flight (shares the reconcile spinner). */
|
||
quarantineRestorePending?: boolean;
|
||
/** Whether a reconcile (forward, override, or quarantine-restore) is currently in flight. */
|
||
reconcilePending?: boolean;
|
||
/** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */
|
||
canFalsePositive?: boolean;
|
||
/**
|
||
* Rendering density. `full` (default) shows the complete metadata table; `compact` drops the
|
||
* metadata rows for embedding beside a run on the agent run page, keeping the header, divergence
|
||
* diagnosis, and action footer.
|
||
*/
|
||
variant?: "full" | "compact";
|
||
className?: string;
|
||
}
|
||
|
||
const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
|
||
missing_disposition: "Missing Disposition",
|
||
deliberate_wait_without_target: "Wait Without A Target",
|
||
stranded_assigned_issue: "Stranded Task",
|
||
workspace_validation: "Workspace Validation",
|
||
configuration_validation: "Configuration Validation",
|
||
active_run_watchdog: "Active Watchdog",
|
||
issue_graph_liveness: "Task Needs Next Step",
|
||
};
|
||
|
||
const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
|
||
missing_disposition:
|
||
"This task's run finished, but no next step was chosen. Choose what happens next — try the task again, mark it done, or send it for review.",
|
||
deliberate_wait_without_target:
|
||
"This task's last run stopped to wait, but there is no reviewer, blocker, monitor, or approval to wait for. Paperclip is repairing the next step; the task stays with its owner.",
|
||
stranded_assigned_issue:
|
||
"Paperclip retried this task's last run, but there is still no queued run, reviewer, blocker, or other next owner. To get it moving, choose what happens next — try the task again, mark it done, or send it for review.",
|
||
workspace_validation:
|
||
"Paperclip stopped this run because the task's git workspace could not be validated.",
|
||
configuration_validation:
|
||
"Paperclip stopped before dispatching this run because required secret/env bindings are missing.",
|
||
active_run_watchdog:
|
||
"The active run has been silent. Recovery is observing without interrupting it.",
|
||
issue_graph_liveness:
|
||
"Paperclip could not find a clear next step for this open task. Choose whether to continue work, send it for review, mark it done, or record what is blocking it.",
|
||
};
|
||
|
||
/** Shared shell for the retry-timing pill so every timing state reads as the same control. */
|
||
const RETRY_PILL_CLASS =
|
||
"rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-(length:--text-micro) text-muted-foreground";
|
||
|
||
const STATE_TONE: Record<RecoveryCardCardState, {
|
||
label: string;
|
||
containerClass: string;
|
||
iconWrapClass: string;
|
||
iconClass: string;
|
||
labelClass: string;
|
||
Icon: typeof TriangleAlert;
|
||
divider: string;
|
||
}> = {
|
||
needed: {
|
||
label: "RECOVERY NEEDED",
|
||
containerClass:
|
||
"border-amber-300/70 bg-amber-50/85 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
|
||
iconWrapClass: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200",
|
||
iconClass: "text-amber-700 dark:text-amber-300",
|
||
labelClass: "text-amber-900 dark:text-amber-200",
|
||
Icon: TriangleAlert,
|
||
divider: "border-amber-300/60 dark:border-amber-500/30",
|
||
},
|
||
in_progress: {
|
||
label: "RECOVERY IN PROGRESS",
|
||
containerClass:
|
||
"border-sky-300/70 bg-sky-50/80 text-sky-950 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-100",
|
||
iconWrapClass: "bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200",
|
||
iconClass: "text-sky-700 dark:text-sky-300",
|
||
labelClass: "text-sky-900 dark:text-sky-200",
|
||
Icon: RefreshCw,
|
||
divider: "border-sky-300/60 dark:border-sky-500/30",
|
||
},
|
||
observe_only: {
|
||
label: "OBSERVING ACTIVE RUN",
|
||
containerClass:
|
||
"border-border bg-muted/40 text-foreground dark:bg-muted/20",
|
||
iconWrapClass: "bg-muted text-foreground/70",
|
||
iconClass: "text-muted-foreground",
|
||
labelClass: "text-muted-foreground",
|
||
Icon: Eye,
|
||
divider: "border-border/70",
|
||
},
|
||
escalated: {
|
||
label: "RECOVERY ESCALATED",
|
||
containerClass:
|
||
"border-red-400/60 bg-red-50/85 text-red-950 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100",
|
||
iconWrapClass: "bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200",
|
||
iconClass: "text-red-700 dark:text-red-300",
|
||
labelClass: "text-red-900 dark:text-red-200",
|
||
Icon: OctagonAlert,
|
||
divider: "border-red-400/50 dark:border-red-500/30",
|
||
},
|
||
resolved: {
|
||
label: "RECOVERY RESOLVED",
|
||
containerClass:
|
||
"border-emerald-300/70 bg-emerald-50/80 text-emerald-950 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100",
|
||
iconWrapClass: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200",
|
||
iconClass: "text-emerald-700 dark:text-emerald-300",
|
||
labelClass: "text-emerald-900 dark:text-emerald-200",
|
||
Icon: Sparkles,
|
||
divider: "border-emerald-300/60 dark:border-emerald-500/30",
|
||
},
|
||
};
|
||
|
||
const OUTCOME_LABEL: Record<IssueRecoveryActionOutcome, string> = {
|
||
restored: "restored",
|
||
handed_back: "handed back to original owner",
|
||
owner_completed: "completed by recovery owner",
|
||
delegated: "delegated to follow-up",
|
||
false_positive: "false positive",
|
||
blocked: "blocked",
|
||
escalated: "escalated",
|
||
cancelled: "cancelled",
|
||
};
|
||
|
||
function readEvidenceString(value: unknown): string | null {
|
||
if (typeof value !== "string") return null;
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return null;
|
||
return trimmed.length > 240 ? `${trimmed.slice(0, 237)}…` : trimmed;
|
||
}
|
||
|
||
// Human-sentence evidence sources render as prose; code-shaped sources
|
||
// (error codes, statuses) stay in the mono treatment used for run ids.
|
||
const PROSE_EVIDENCE_KEYS = ["summary", "detectedProgressSummary", "missingDisposition", "retryReason"] as const;
|
||
const CODE_EVIDENCE_KEYS = ["latestRunErrorCode", "latestRunStatus", "latestIssueStatus"] as const;
|
||
|
||
function pickEvidenceSummary(action: IssueRecoveryAction): { text: string; isCode: boolean } | null {
|
||
const evidence = action.evidence ?? {};
|
||
for (const key of PROSE_EVIDENCE_KEYS) {
|
||
const next = readEvidenceString(evidence[key]);
|
||
if (next) return { text: next, isCode: false };
|
||
}
|
||
for (const key of CODE_EVIDENCE_KEYS) {
|
||
const next = readEvidenceString(evidence[key]);
|
||
if (next) return { text: next, isCode: true };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function readEvidenceRunId(action: IssueRecoveryAction, key: "sourceRunId" | "correctiveRunId" | "latestRunId") {
|
||
const evidence = action.evidence ?? {};
|
||
const next = readEvidenceString(evidence[key]);
|
||
return next;
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||
return value && typeof value === "object" && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: null;
|
||
}
|
||
|
||
function asNonEmptyString(value: unknown): string | null {
|
||
if (typeof value !== "string") return null;
|
||
const trimmed = value.trim();
|
||
return trimmed.length > 0 ? trimmed : null;
|
||
}
|
||
|
||
function asAncestryVerdict(value: unknown): GitWorktreeBranchAncestryVerdict | null {
|
||
return value === "ancestor" || value === "diverged" || value === "unknown" ? value : null;
|
||
}
|
||
|
||
function formatShortSha(sha: string | null): string | null {
|
||
if (!sha) return null;
|
||
return sha.length > 10 ? sha.slice(0, 10) : sha;
|
||
}
|
||
|
||
/**
|
||
* Diagnosis derived from a workspace_validation recovery action whose underlying failure is a
|
||
* git-worktree branch incoherence. The evidence carries the recorded ("expected") branch, the
|
||
* live ("actual"/checked-out) branch, both HEAD shas, and a server-computed ancestry verdict +
|
||
* plain-language explanation of why the run was declined.
|
||
*/
|
||
interface WorkspaceContention {
|
||
claimedByIssueId: string | null;
|
||
claimedByIssueIdentifier: string | null;
|
||
/** True when the claiming workspace has a queued/running run (not just a stale claim). */
|
||
hasActiveRun: boolean;
|
||
}
|
||
|
||
interface WorkspaceDivergence {
|
||
expectedBranch: string | null;
|
||
liveBranch: string | null;
|
||
expectedHeadSha: string | null;
|
||
liveHeadSha: string | null;
|
||
ancestryVerdict: GitWorktreeBranchAncestryVerdict | null;
|
||
plainLanguageReason: string | null;
|
||
cleanliness: "clean" | "dirty" | "unknown" | null;
|
||
/** Number of dirty (uncommitted) status entries in the live worktree, when known. */
|
||
dirtyFileCount: number | null;
|
||
/** Sample of dirty paths (already truncated server-side) for the confirm step. */
|
||
dirtyPathSample: string[];
|
||
/**
|
||
* Another workspace is holding the live branch. When present, the lossless quarantine repair is
|
||
* refused server-side — re-issuing on an isolated workspace is the recommended path instead.
|
||
*/
|
||
contention: WorkspaceContention | null;
|
||
/**
|
||
* Preview of the rescue branch the quarantine repair will create. The server appends a UTC
|
||
* timestamp at repair time, so this is the stable prefix only (rendered with a trailing marker).
|
||
*/
|
||
rescueBranchPreview: string;
|
||
/** Ref a re-issue should base off — the live branch when known, else the live HEAD sha. */
|
||
reissueBaseRef: string | null;
|
||
}
|
||
|
||
/** Mirrors the server's `sanitizeBranchName` for a faithful rescue-branch preview. */
|
||
function sanitizeBranchComponent(value: string): string {
|
||
return (
|
||
value
|
||
.trim()
|
||
.replace(/[^A-Za-z0-9._/-]+/g, "-")
|
||
.replace(/-+/g, "-")
|
||
.replace(/^[-/.]+|[-/.]+$/g, "")
|
||
.slice(0, 120) || "issue"
|
||
);
|
||
}
|
||
|
||
function buildRescueBranchPreview(sourceIdentifier: string | null): string {
|
||
return `paperclip/rescue/${sanitizeBranchComponent(sourceIdentifier ?? "issue")}/`;
|
||
}
|
||
|
||
function asStringArray(value: unknown): string[] {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
||
}
|
||
|
||
function asNonNegativeInt(value: unknown): number | null {
|
||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
||
}
|
||
|
||
function readContention(value: unknown): WorkspaceContention | null {
|
||
const record = asRecord(value);
|
||
if (!record) return null;
|
||
const activeRun = asRecord(record.activeRun);
|
||
return {
|
||
claimedByIssueId: asNonEmptyString(record.claimedByIssueId),
|
||
claimedByIssueIdentifier: asNonEmptyString(record.claimedByIssueIdentifier),
|
||
hasActiveRun: activeRun !== null,
|
||
};
|
||
}
|
||
|
||
function readWorkspaceDivergence(action: IssueRecoveryAction): WorkspaceDivergence | null {
|
||
if (action.kind !== "workspace_validation") return null;
|
||
const workspaceValidation = asRecord(action.evidence?.workspaceValidation);
|
||
if (!workspaceValidation) return null;
|
||
if (workspaceValidation.reason !== "git_worktree_branch_incoherence") return null;
|
||
const provenance = asRecord(workspaceValidation.provenance) ?? {};
|
||
const expectedBranch = asNonEmptyString(workspaceValidation.expectedBranch);
|
||
const liveBranch = asNonEmptyString(workspaceValidation.actualBranch);
|
||
const expectedHeadSha = asNonEmptyString(provenance.expectedHeadSha);
|
||
const liveHeadSha = asNonEmptyString(provenance.actualHeadSha);
|
||
const cleanlinessRaw = workspaceValidation.cleanliness;
|
||
const cleanliness =
|
||
cleanlinessRaw === "clean" || cleanlinessRaw === "dirty" || cleanlinessRaw === "unknown"
|
||
? cleanlinessRaw
|
||
: null;
|
||
const sourceIdentifier = asNonEmptyString(workspaceValidation.sourceIdentifier);
|
||
return {
|
||
expectedBranch,
|
||
liveBranch,
|
||
expectedHeadSha,
|
||
liveHeadSha,
|
||
ancestryVerdict: asAncestryVerdict(provenance.ancestryVerdict),
|
||
plainLanguageReason: asNonEmptyString(provenance.plainLanguageReason),
|
||
cleanliness,
|
||
dirtyFileCount: asNonNegativeInt(workspaceValidation.statusEntryCount),
|
||
dirtyPathSample: asStringArray(workspaceValidation.dirtyPathSample),
|
||
contention: readContention(workspaceValidation.contention),
|
||
rescueBranchPreview: buildRescueBranchPreview(sourceIdentifier),
|
||
reissueBaseRef: liveBranch ?? liveHeadSha,
|
||
};
|
||
}
|
||
|
||
const ANCESTRY_BADGE: Record<
|
||
GitWorktreeBranchAncestryVerdict,
|
||
{ label: string; className: string }
|
||
> = {
|
||
ancestor: {
|
||
label: "Forward-only",
|
||
className: "border-emerald-400/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||
},
|
||
diverged: {
|
||
label: "Diverged",
|
||
className: "border-red-400/50 bg-red-500/10 text-red-700 dark:text-red-300",
|
||
},
|
||
unknown: {
|
||
label: "Ancestry unknown",
|
||
className: "border-border bg-muted/60 text-muted-foreground",
|
||
},
|
||
};
|
||
|
||
function BranchFacet({
|
||
label,
|
||
branch,
|
||
sha,
|
||
}: {
|
||
label: string;
|
||
branch: string | null;
|
||
sha: string | null;
|
||
}) {
|
||
const shortSha = formatShortSha(sha);
|
||
return (
|
||
<div className="min-w-0 rounded-md border border-border/70 bg-background/60 px-2.5 py-2">
|
||
<div className="text-(length:--text-nano) font-medium uppercase tracking-(--tracking-label) text-muted-foreground">
|
||
{label}
|
||
</div>
|
||
<div className="mt-1 flex items-center gap-1.5">
|
||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||
{branch ? (
|
||
<code className="truncate font-mono text-xs text-foreground/90">{branch}</code>
|
||
) : (
|
||
<span className="text-xs italic text-muted-foreground">detached / unknown</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-0.5 pl-5 font-mono text-(length:--text-micro) text-muted-foreground">
|
||
{shortSha ? `@ ${shortSha}` : "@ —"}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DivergenceDiagnosis({
|
||
divergence,
|
||
dividerClass,
|
||
}: {
|
||
divergence: WorkspaceDivergence;
|
||
dividerClass: string;
|
||
}) {
|
||
const badge = ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"];
|
||
return (
|
||
<div
|
||
data-testid="recovery-divergence-diagnosis"
|
||
className={cn(
|
||
"space-y-2.5 border-t bg-background/40 px-3 py-3 dark:bg-background/20 sm:px-4",
|
||
dividerClass,
|
||
)}
|
||
>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
|
||
Divergence diagnosis
|
||
</span>
|
||
<Badge variant="outline"
|
||
data-testid="recovery-ancestry-verdict"
|
||
className={cn(
|
||
"text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-label)",
|
||
badge.className,
|
||
)}
|
||
>
|
||
{badge.label}
|
||
</Badge>
|
||
</div>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<BranchFacet
|
||
label="Expected · recorded"
|
||
branch={divergence.expectedBranch}
|
||
sha={divergence.expectedHeadSha}
|
||
/>
|
||
<BranchFacet
|
||
label="Live · checked out"
|
||
branch={divergence.liveBranch}
|
||
sha={divergence.liveHeadSha}
|
||
/>
|
||
</div>
|
||
{divergence.plainLanguageReason ? (
|
||
<p className="text-xs leading-5 text-foreground/80">{divergence.plainLanguageReason}</p>
|
||
) : null}
|
||
{divergence.contention ? (
|
||
<p
|
||
data-testid="recovery-contention-notice"
|
||
className="flex items-start gap-1.5 rounded-md border border-amber-400/40 bg-amber-500/5 px-2.5 py-1.5 text-xs leading-5 text-amber-900 dark:text-amber-200"
|
||
>
|
||
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||
<span>
|
||
Worktree claimed by{" "}
|
||
<code className="font-mono text-foreground/90">{contentionLabel(divergence.contention)}</code>{" "}
|
||
{divergence.contention.hasActiveRun ? "(active run)" : "(claim held)"} — the lossless repair
|
||
can't run while another workspace holds the live branch.
|
||
</span>
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function contentionLabel(contention: WorkspaceContention): string {
|
||
return (
|
||
contention.claimedByIssueIdentifier ??
|
||
(contention.claimedByIssueId ? `issue ${contention.claimedByIssueId.slice(0, 8)}` : "another task")
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Action 2 — the audited break-glass override. Gated by an explicit confirm step that *restates the
|
||
* divergence* (both branches + short SHAs + ancestry verdict) and a required, non-empty reason: the
|
||
* confirm button stays disabled until the operator records why. The server re-checks the actor and
|
||
* permission and appends the reason to the audit log — this UI gate is the operator-facing guardrail,
|
||
* not the security boundary.
|
||
*/
|
||
function BreakGlassOverride({
|
||
divergence,
|
||
onConfirm,
|
||
pending,
|
||
}: {
|
||
divergence: WorkspaceDivergence;
|
||
onConfirm: (reason: string) => void;
|
||
pending: boolean;
|
||
}) {
|
||
const [reason, setReason] = useState("");
|
||
const trimmedReason = reason.trim();
|
||
const canSubmit = trimmedReason.length > 0 && !pending;
|
||
const verdictBadge = ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"];
|
||
const expectedSha = formatShortSha(divergence.expectedHeadSha);
|
||
const liveSha = formatShortSha(divergence.liveHeadSha);
|
||
return (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={pending}
|
||
data-testid="recovery-action-breakglass-trigger"
|
||
className="border-red-400/60 text-red-700 hover:bg-red-500/10 dark:border-red-500/40 dark:text-red-300"
|
||
>
|
||
<OctagonAlert className="h-3.5 w-3.5" aria-hidden />
|
||
I've verified this — reconcile anyway
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align="start"
|
||
sideOffset={6}
|
||
aria-labelledby="recovery-breakglass-title"
|
||
className="w-96 max-w-(--sz-calc-4) space-y-3 p-3"
|
||
>
|
||
<div className="space-y-1">
|
||
<div
|
||
id="recovery-breakglass-title"
|
||
className="flex items-center gap-1.5 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-red-700 dark:text-red-300"
|
||
>
|
||
<OctagonAlert className="h-3.5 w-3.5" aria-hidden />
|
||
Break-glass reconciliation
|
||
</div>
|
||
<p className="text-xs leading-5 text-muted-foreground">
|
||
This overrides Paperclip's safety check and points the recorded workspace at the live
|
||
branch{" "}
|
||
<span className="font-medium text-foreground/80">without an ancestry proof</span>. Confirm
|
||
the divergence below and record why before continuing.
|
||
</p>
|
||
</div>
|
||
<dl
|
||
data-testid="recovery-breakglass-restated-divergence"
|
||
className="space-y-1.5 rounded-md border border-red-400/40 bg-red-500/5 px-2.5 py-2 text-(length:--text-micro)"
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="shrink-0 text-muted-foreground">Recorded · expected</dt>
|
||
<dd className="min-w-0 truncate font-mono text-foreground/90">
|
||
{divergence.expectedBranch ?? "detached"}
|
||
{expectedSha ? ` @ ${expectedSha}` : ""}
|
||
</dd>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="shrink-0 text-muted-foreground">Live · checked out</dt>
|
||
<dd className="min-w-0 truncate font-mono text-foreground/90">
|
||
{divergence.liveBranch ?? "detached"}
|
||
{liveSha ? ` @ ${liveSha}` : ""}
|
||
</dd>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="shrink-0 text-muted-foreground">Ancestry verdict</dt>
|
||
<dd className="font-medium">{verdictBadge.label}</dd>
|
||
</div>
|
||
</dl>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="recovery-breakglass-reason" className="text-(length:--text-micro) text-muted-foreground">
|
||
Reason <span className="text-red-600 dark:text-red-400">(required — recorded in the audit log)</span>
|
||
</Label>
|
||
<Textarea
|
||
id="recovery-breakglass-reason"
|
||
value={reason}
|
||
onChange={(event) => setReason(event.target.value)}
|
||
placeholder="e.g. Verified the live branch carries only the intended follow-up commits; safe to adopt."
|
||
className="min-h-20 text-xs"
|
||
data-testid="recovery-breakglass-reason"
|
||
aria-required="true"
|
||
/>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="destructive"
|
||
className="w-full"
|
||
disabled={!canSubmit}
|
||
data-testid="recovery-action-breakglass-confirm"
|
||
onClick={() => {
|
||
if (!canSubmit) return;
|
||
onConfirm(trimmedReason);
|
||
}}
|
||
>
|
||
{pending ? "Reconciling…" : "Reconcile anyway (break-glass)"}
|
||
</Button>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The lossless repair — quarantine the dirty worktree onto a rescue branch, then restore the
|
||
* recorded branch. Unlike break-glass, this is *non-destructive* (no work is lost, so no reason is
|
||
* required): the confirm popover simply restates what will happen — the dirty file count, that the
|
||
* live branch is left untouched, the rescue branch that will hold the changes, and the recorded
|
||
* branch to be restored. Disabled (with an inline explanation, no popover) when the live branch is
|
||
* contended by another workspace, since the server refuses the repair in that case.
|
||
*/
|
||
function RepairWorkspace({
|
||
divergence,
|
||
onConfirm,
|
||
pending,
|
||
disabled,
|
||
disabledReason,
|
||
}: {
|
||
divergence: WorkspaceDivergence;
|
||
onConfirm: () => void;
|
||
pending: boolean;
|
||
disabled: boolean;
|
||
disabledReason: string | null;
|
||
}) {
|
||
const dirtyCount = divergence.dirtyFileCount;
|
||
const dirtyLabel =
|
||
dirtyCount === null
|
||
? "Uncommitted changes"
|
||
: `${dirtyCount} uncommitted ${dirtyCount === 1 ? "change" : "changes"}`;
|
||
const trigger = (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={pending || disabled}
|
||
data-testid="recovery-action-repair-trigger"
|
||
className="border-sky-400/50 text-sky-700 hover:bg-sky-500/10 dark:border-sky-500/40 dark:text-sky-300"
|
||
>
|
||
{pending ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||
) : (
|
||
<Wrench className="h-3.5 w-3.5" aria-hidden />
|
||
)}
|
||
Repair workspace — quarantine changes & restore branch
|
||
</Button>
|
||
);
|
||
if (disabled) {
|
||
// Contended: the server refuses the repair, so render a plainly disabled control with the reason
|
||
// inline rather than a popover the operator can't act on.
|
||
return (
|
||
<div className="flex flex-col gap-1" data-testid="recovery-action-repair-disabled">
|
||
{trigger}
|
||
{disabledReason ? (
|
||
<span className="text-(length:--text-nano) leading-4 text-muted-foreground">
|
||
{disabledReason}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<Popover>
|
||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||
<PopoverContent
|
||
align="start"
|
||
sideOffset={6}
|
||
aria-labelledby="recovery-repair-title"
|
||
className="w-96 max-w-(--sz-calc-4) space-y-3 p-3"
|
||
>
|
||
<div className="space-y-1">
|
||
<div
|
||
id="recovery-repair-title"
|
||
className="flex items-center gap-1.5 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-sky-700 dark:text-sky-300"
|
||
>
|
||
<Wrench className="h-3.5 w-3.5" aria-hidden />
|
||
Repair workspace
|
||
</div>
|
||
<p className="text-xs leading-5 text-muted-foreground">
|
||
This is lossless — no reason required. Your uncommitted changes are committed onto a fresh
|
||
rescue branch, then the recorded branch is restored so the task can resume. The live branch
|
||
is left exactly as it is.
|
||
</p>
|
||
</div>
|
||
<dl
|
||
data-testid="recovery-repair-restated"
|
||
className="space-y-1.5 rounded-md border border-sky-400/30 bg-sky-500/5 px-2.5 py-2 text-(length:--text-micro)"
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="shrink-0 text-muted-foreground">Dirty changes</dt>
|
||
<dd data-testid="recovery-repair-dirty-count" className="font-medium text-foreground/90">
|
||
{dirtyLabel}
|
||
</dd>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="shrink-0 text-muted-foreground">Live branch</dt>
|
||
<dd className="min-w-0 truncate font-mono text-foreground/90">
|
||
{divergence.liveBranch ?? "detached"}
|
||
<span className="ml-1 font-sans text-muted-foreground">(left untouched)</span>
|
||
</dd>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="shrink-0 text-muted-foreground">Rescue branch</dt>
|
||
<dd
|
||
data-testid="recovery-repair-rescue-branch"
|
||
className="min-w-0 truncate font-mono text-foreground/90"
|
||
>
|
||
{divergence.rescueBranchPreview}
|
||
<span className="text-muted-foreground"><timestamp></span>
|
||
</dd>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="shrink-0 text-muted-foreground">Restore to</dt>
|
||
<dd className="min-w-0 truncate font-mono text-foreground/90">
|
||
{divergence.expectedBranch ?? "recorded branch"}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
className="w-full"
|
||
disabled={pending}
|
||
data-testid="recovery-action-repair-confirm"
|
||
onClick={() => {
|
||
if (pending) return;
|
||
onConfirm();
|
||
}}
|
||
>
|
||
{pending ? "Repairing…" : "Quarantine changes & restore branch"}
|
||
</Button>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
function readWakePolicySummary(action: IssueRecoveryAction): string | null {
|
||
const policy = action.wakePolicy;
|
||
if (!policy) return null;
|
||
const type = readEvidenceString(policy.type);
|
||
if (!type) return null;
|
||
if (type === "wake_owner") return "An agent will be asked to choose the next step";
|
||
if (type === "bounded_owner_disposition_repair") {
|
||
return "Paperclip is retrying the original owner";
|
||
}
|
||
if (type === "bounded_recovery_owner") return "A recovery owner is repairing the next step";
|
||
if (type === "board_escalation") return "Board decision required";
|
||
if (type === "manual") return "Manual follow-up needed";
|
||
if (type === "manual_repair_required") return "Repair needed before retry";
|
||
if (type === "monitor") {
|
||
const interval = readEvidenceString(policy.intervalLabel);
|
||
return interval ? `Check scheduled · ${interval}` : "Check scheduled";
|
||
}
|
||
return type.replaceAll("_", " ");
|
||
}
|
||
|
||
function formatTimeShort(value: string | Date | null | undefined): string | null {
|
||
if (!value) return null;
|
||
try {
|
||
const date = value instanceof Date ? value : new Date(value);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
const now = Date.now();
|
||
const diffMs = date.getTime() - now;
|
||
const absMin = Math.round(Math.abs(diffMs) / 60_000);
|
||
if (absMin < 60) {
|
||
return diffMs >= 0 ? `in ${absMin}m` : `${absMin}m ago`;
|
||
}
|
||
return date.toLocaleString(undefined, {
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
});
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function shortenRunId(runId: string | null | undefined) {
|
||
if (!runId) return null;
|
||
if (runId.length <= 12) return runId;
|
||
return runId.slice(0, 8);
|
||
}
|
||
|
||
function MetadataRow({
|
||
label,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="grid grid-cols-(--gtc-8) gap-x-3 gap-y-0 px-3 py-1.5 text-xs sm:px-4">
|
||
<dt className="truncate text-(length:--text-micro) font-medium uppercase tracking-(--tracking-label) text-muted-foreground">
|
||
{label}
|
||
</dt>
|
||
<dd className="min-w-0 break-words text-foreground/90">{children}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MissingValue() {
|
||
return <span className="text-muted-foreground">—</span>;
|
||
}
|
||
|
||
function AgentLink({
|
||
agentId,
|
||
agentMap,
|
||
fallback,
|
||
}: {
|
||
agentId: string | null | undefined;
|
||
agentMap?: ReadonlyMap<string, Agent>;
|
||
fallback?: string | null;
|
||
}) {
|
||
if (!agentId) {
|
||
return fallback ? <span>{fallback}</span> : <MissingValue />;
|
||
}
|
||
const agent = agentMap?.get(agentId);
|
||
const label = agent?.name ?? `agent ${agentId.slice(0, 8)}`;
|
||
if (agent) {
|
||
return (
|
||
<Link
|
||
to={agentUrl(agent)}
|
||
className="rounded-sm font-medium underline-offset-2 hover:underline"
|
||
>
|
||
{label}
|
||
</Link>
|
||
);
|
||
}
|
||
return <span className="font-medium">{label}</span>;
|
||
}
|
||
|
||
function RunChip({
|
||
runId,
|
||
agentId,
|
||
status,
|
||
}: {
|
||
runId: string | null;
|
||
agentId: string | null | undefined;
|
||
status?: string | null;
|
||
}) {
|
||
if (!runId) return <MissingValue />;
|
||
const short = shortenRunId(runId);
|
||
const inner = (
|
||
<>
|
||
<code className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-(length:--text-micro) text-foreground/80">
|
||
run {short}
|
||
</code>
|
||
{status ? (
|
||
<span className="font-sans text-(length:--text-micro) text-muted-foreground">{status}</span>
|
||
) : null}
|
||
</>
|
||
);
|
||
if (agentId) {
|
||
return (
|
||
<Link
|
||
to={`/agents/${agentId}/runs/${runId}`}
|
||
className="inline-flex items-center gap-2 rounded-sm underline-offset-2 hover:underline"
|
||
>
|
||
{inner}
|
||
</Link>
|
||
);
|
||
}
|
||
return <span className="inline-flex items-center gap-2">{inner}</span>;
|
||
}
|
||
|
||
function formatTimeAbsolute(value: string | Date | null | undefined): string | null {
|
||
if (!value) return null;
|
||
const date = value instanceof Date ? value : new Date(value);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
return date.toLocaleString(undefined, {
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Headline for an action carrying a bounded retry lineage. It describes the recovery
|
||
* state and next step without implying that a repair owner owns the deliverable.
|
||
*/
|
||
function lineageHeadline(lineage: RecoveryRetryLineage): string {
|
||
if (lineage.lane === "native_run") {
|
||
if (lineage.liveRunId) return "Paperclip is recovering the existing run.";
|
||
if (lineage.exhausted) return "Paperclip could not recover the existing run within its retry budget. Review the recorded failure before retrying.";
|
||
if (lineage.retryExpired) return "The retry for the existing run came due and did not start. Review the recorded failure and retry when ready.";
|
||
if (lineage.nextRetryAt) return "Paperclip has scheduled another attempt to resume the existing run.";
|
||
return "The existing run still needs recovery. Review the recorded failure before retrying.";
|
||
}
|
||
// An attempt that came due and never ran leaves nobody working on this task, even though
|
||
// attempts remain on paper. Say so before any lane wording that ends in "no action needed".
|
||
if (lineage.retryExpired) {
|
||
return "This task's automatic retry came due and did not run, so nothing is moving it forward right now. Someone must retry it or record the next step.";
|
||
}
|
||
if (lineage.lane === "source_owner") {
|
||
return lineage.exhausted
|
||
? "This task's last run stopped to wait, but nothing was waiting for it. The original owner has used every automatic repair attempt, so the next step needs a decision. The task stays with its owner."
|
||
: "This task's last run stopped to wait, but nothing was waiting for it. Paperclip is retrying the original owner to record a real next step. The task stays with its owner, and no action is needed yet.";
|
||
}
|
||
if (lineage.lane === "recovery_owner") {
|
||
return "The original owner could not record a next step within its retry budget. A recovery owner is now repairing the path only — the task itself still belongs to its original owner.";
|
||
}
|
||
return "Automatic recovery is exhausted, so the board must choose the next step. The task itself still belongs to its original owner.";
|
||
}
|
||
|
||
/** Spent/remaining attempts as pips. The readable count lives beside it in text. */
|
||
function AttemptMeter({ lineage }: { lineage: RecoveryRetryLineage }) {
|
||
if (lineage.maxAttempts === null || lineage.maxAttempts > 12) return null;
|
||
const spent = Math.min(lineage.attempt, lineage.maxAttempts);
|
||
return (
|
||
<span className="inline-flex items-center gap-1" aria-hidden>
|
||
{Array.from({ length: lineage.maxAttempts }, (_, index) => (
|
||
<span
|
||
key={index}
|
||
className={cn(
|
||
"size-1.5 rounded-full",
|
||
index < spent ? "bg-current opacity-80" : "bg-current opacity-25",
|
||
)}
|
||
/>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
const RESOLVE_OPTIONS: Array<{
|
||
outcome: RecoveryResolveOutcome;
|
||
label: string;
|
||
description: string;
|
||
destructive?: boolean;
|
||
boardOnly?: boolean;
|
||
}> = [
|
||
{
|
||
outcome: "todo",
|
||
label: "Try again",
|
||
description: "Dismiss recovery and return the source task to todo.",
|
||
},
|
||
{
|
||
outcome: "done",
|
||
label: "Mark task done",
|
||
description: "Restore by recording the requested work as complete.",
|
||
},
|
||
{
|
||
outcome: "in_review",
|
||
label: "Send for review",
|
||
description: "Hand off to a reviewer with a real review path.",
|
||
},
|
||
{
|
||
outcome: "false_positive_done",
|
||
label: "False positive, done",
|
||
description: "Dismiss recovery and mark the source task complete.",
|
||
destructive: true,
|
||
boardOnly: true,
|
||
},
|
||
{
|
||
outcome: "false_positive_in_review",
|
||
label: "False positive, review",
|
||
description: "Dismiss recovery and send the source task for review.",
|
||
destructive: true,
|
||
boardOnly: true,
|
||
},
|
||
];
|
||
|
||
export function IssueRecoveryActionCard({
|
||
action,
|
||
agentMap,
|
||
scheduledRetry = null,
|
||
forcedState,
|
||
onResolve,
|
||
onReissueIsolated,
|
||
reissuePending = false,
|
||
onReconcileForward,
|
||
onBreakGlassOverride,
|
||
onQuarantineRestore,
|
||
quarantineRestorePending = false,
|
||
canBreakGlass = false,
|
||
reconcilePending = false,
|
||
canFalsePositive = false,
|
||
variant = "full",
|
||
className,
|
||
}: IssueRecoveryActionCardProps) {
|
||
const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls();
|
||
const liveness = useMemo(() => ({ scheduledRetry }), [scheduledRetry]);
|
||
const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action, liveness);
|
||
const tone = STATE_TONE[cardState];
|
||
const ToneIcon = tone.Icon;
|
||
const divergence = useMemo(() => readWorkspaceDivergence(action), [action]);
|
||
const lineage = useMemo(() => readRecoveryRetryLineage(action, liveness), [action, liveness]);
|
||
|
||
const headline = useMemo(() => {
|
||
if (cardState === "resolved" && action.outcome) {
|
||
return `Recovery resolved as ${OUTCOME_LABEL[action.outcome] ?? action.outcome}.`;
|
||
}
|
||
if (
|
||
(cardState === "needed" || cardState === "escalated") &&
|
||
action.kind === "active_run_watchdog" &&
|
||
action.ownerType === "board"
|
||
) {
|
||
return "This recovery needs a human decision. Review the recorded failure and choose the next step.";
|
||
}
|
||
if (lineage) return lineageHeadline(lineage);
|
||
return KIND_HEADLINE[action.kind] ?? KIND_HEADLINE.missing_disposition;
|
||
}, [action.kind, action.outcome, action.ownerType, cardState, lineage]);
|
||
|
||
// A lane with no path left must not keep advertising a retry that will never run — whether
|
||
// the budget ran out or the scheduled attempt simply never fired.
|
||
const wakeSummary = lineage?.retryExpired
|
||
? "The scheduled retry did not run — a retry or a decision is needed"
|
||
: lineage?.exhausted && !lineage.liveRunId && lineage.lane !== "board"
|
||
? "Automatic retries are finished — a decision is needed"
|
||
: readWakePolicySummary(action);
|
||
const evidenceSummary = pickEvidenceSummary(action);
|
||
const sourceRunId = readEvidenceRunId(action, "sourceRunId") ?? readEvidenceRunId(action, "latestRunId");
|
||
const correctiveRunId = readEvidenceRunId(action, "correctiveRunId");
|
||
// The lineage rows below already carry the attempt budget, so the generic chip only
|
||
// covers actions without one.
|
||
const showAttempt = !lineage && action.attemptCount > 1 && action.maxAttempts !== null;
|
||
const sourceOwnerAgentId = action.returnOwnerAgentId ?? action.previousOwnerAgentId;
|
||
const recoveryOwnerIsSourceOwner =
|
||
action.ownerType === "agent" &&
|
||
action.ownerAgentId !== null &&
|
||
action.ownerAgentId === sourceOwnerAgentId;
|
||
const retryOffset = lineage ? formatRecoveryRetryOffset(lineage) : null;
|
||
const attemptLabel = lineage ? formatRecoveryAttemptLabel(lineage) : null;
|
||
const showTimeoutInline = (() => {
|
||
// The retry-progress row is the single place a lineage reports its timing.
|
||
if (lineage) return false;
|
||
if (!action.timeoutAt) return false;
|
||
try {
|
||
const date = action.timeoutAt instanceof Date ? action.timeoutAt : new Date(action.timeoutAt);
|
||
const diffMs = date.getTime() - Date.now();
|
||
return diffMs > 0 && diffMs < 60 * 60 * 1000;
|
||
} catch {
|
||
return false;
|
||
}
|
||
})();
|
||
const updatedAtLabel = formatTimeShort(action.updatedAt);
|
||
|
||
const ariaState = ({
|
||
needed: "needed",
|
||
in_progress: "in progress",
|
||
observe_only: "observing active run",
|
||
escalated: "escalated",
|
||
resolved: "resolved",
|
||
} satisfies Record<RecoveryCardCardState, string>)[cardState];
|
||
|
||
const visibleResolveOptions = RESOLVE_OPTIONS.filter((option) => {
|
||
if (action.cause === "native_workspace_finalization_owner_unverified") return false;
|
||
if (isNativeWorkspaceExportRepairCause(action.cause) && ["todo", "done", "in_review"].includes(option.outcome)) return false;
|
||
if (option.outcome === "todo" && requiresExecutionReconciliation(action.cause)) return false;
|
||
if (option.boardOnly && !canFalsePositive) return false;
|
||
return true;
|
||
});
|
||
const showResolveActions = onResolve !== undefined && cardState !== "resolved" && visibleResolveOptions.length > 0;
|
||
const reissueBaseRef = divergence?.reissueBaseRef ?? null;
|
||
const showReissueAction =
|
||
workspaceIsolationControlsVisible &&
|
||
onReissueIsolated !== undefined &&
|
||
cardState !== "resolved" &&
|
||
divergence !== null &&
|
||
reissueBaseRef !== null;
|
||
const reissueVerdictBadge = divergence
|
||
? ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"]
|
||
: null;
|
||
// Action 1 — the ancestry-proven safe path. Only offered when the server-computed verdict is
|
||
// "ancestor"; the server re-verifies before mutating, so this gate mirrors (not replaces) it.
|
||
const showReconcileForward =
|
||
onReconcileForward !== undefined &&
|
||
cardState !== "resolved" &&
|
||
divergence !== null &&
|
||
divergence.ancestryVerdict === "ancestor";
|
||
// Action 2 — the break-glass override. Permission-hidden: absent entirely unless the viewer is a
|
||
// permitted operator. The confirm step (restated divergence + required reason) lives in the popover.
|
||
const showBreakGlass =
|
||
onBreakGlassOverride !== undefined &&
|
||
cardState !== "resolved" &&
|
||
divergence !== null &&
|
||
canBreakGlass;
|
||
// The lossless repair — offered only for a *dirty* divergence (a clean one reconciles forward or
|
||
// via break-glass, with nothing to quarantine). Disabled when the live branch is contended by an
|
||
// active claimant, since the server refuses `quarantine_restore` in that case.
|
||
const repairContention = divergence?.contention ?? null;
|
||
const showRepairAction =
|
||
onQuarantineRestore !== undefined &&
|
||
cardState !== "resolved" &&
|
||
divergence !== null &&
|
||
divergence.cleanliness === "dirty";
|
||
const repairDisabledReason = repairContention
|
||
? `Held by ${contentionLabel(repairContention)}${showReissueAction ? " — re-issue on an isolated workspace instead." : "."}`
|
||
: null;
|
||
// When contended, the re-issue is the recommended path, so it takes the primary emphasis and a
|
||
// "Recommended" hint while the repair button is disabled.
|
||
const reissueRecommended = showRepairAction && repairContention !== null;
|
||
const showFooter =
|
||
showResolveActions ||
|
||
showReissueAction ||
|
||
showReconcileForward ||
|
||
showBreakGlass ||
|
||
showRepairAction;
|
||
|
||
if (requiresExecutionReconciliation(action.cause) || action.cause === "native_workspace_sync_out_unsafe_archive") return null;
|
||
|
||
return (
|
||
<section
|
||
role="status"
|
||
aria-label={`Recovery action: ${ariaState}`}
|
||
data-recovery-state={cardState}
|
||
data-recovery-kind={action.kind}
|
||
data-recovery-lane={lineage?.lane}
|
||
className={cn(
|
||
"relative w-full overflow-hidden rounded-lg border text-sm shadow-(--shadow-extract-8)",
|
||
tone.containerClass,
|
||
className,
|
||
)}
|
||
>
|
||
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
|
||
<span
|
||
className={cn(
|
||
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
|
||
tone.iconWrapClass,
|
||
)}
|
||
aria-hidden
|
||
>
|
||
<ToneIcon className={cn("h-4 w-4", tone.iconClass)} />
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow)">
|
||
<span className={tone.labelClass}>{tone.label}</span>
|
||
<span className="text-muted-foreground/60" aria-hidden>·</span>
|
||
<code className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-(length:--text-micro) tracking-normal text-muted-foreground">
|
||
{KIND_LABEL[action.kind] ?? action.kind}
|
||
</code>
|
||
{updatedAtLabel ? (
|
||
<>
|
||
<span className="text-muted-foreground/60" aria-hidden>·</span>
|
||
<span className="font-medium normal-case tracking-normal text-muted-foreground">
|
||
{updatedAtLabel}
|
||
</span>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
<p className="mt-1 text-sm leading-6">{headline}</p>
|
||
</div>
|
||
</header>
|
||
{variant === "compact" ? null : (
|
||
<dl className={cn("border-t bg-background/40 dark:bg-background/20", tone.divider)}>
|
||
{lineage ? (
|
||
<>
|
||
<MetadataRow label="Task owner">
|
||
<span
|
||
className="inline-flex flex-wrap items-center gap-1.5"
|
||
data-testid="recovery-source-owner"
|
||
>
|
||
<AgentLink
|
||
agentId={sourceOwnerAgentId}
|
||
agentMap={agentMap}
|
||
fallback="unassigned"
|
||
/>
|
||
<span className="text-muted-foreground">keeps this task</span>
|
||
</span>
|
||
</MetadataRow>
|
||
<MetadataRow label="Recovery owner">
|
||
<span
|
||
className="inline-flex flex-wrap items-center gap-1.5"
|
||
data-testid="recovery-recovery-owner"
|
||
>
|
||
{lineage.lane === "native_run" && (action.ownerType !== "board" || Boolean(lineage.liveRunId)) ? (
|
||
<>
|
||
<span className="font-medium">Paperclip</span>
|
||
<span className="text-muted-foreground">recovers the existing run</span>
|
||
</>
|
||
) : recoveryOwnerIsSourceOwner ? (
|
||
<span className="font-medium">Original owner — retrying itself</span>
|
||
) : action.ownerType === "agent" && action.ownerAgentId ? (
|
||
<>
|
||
<AgentLink agentId={action.ownerAgentId} agentMap={agentMap} />
|
||
<span className="text-muted-foreground">repairs the next step only</span>
|
||
</>
|
||
) : action.ownerType === "board" ? (
|
||
<>
|
||
<span className="font-medium">Board</span>
|
||
<span className="text-muted-foreground">decides the next step only</span>
|
||
</>
|
||
) : action.ownerType === "user" && action.ownerUserId ? (
|
||
<span className="font-medium">user {action.ownerUserId.slice(0, 6)}</span>
|
||
) : (
|
||
<span className="text-muted-foreground">unassigned — pick one to wake them</span>
|
||
)}
|
||
</span>
|
||
</MetadataRow>
|
||
<MetadataRow label="Retry progress">
|
||
<span
|
||
className="inline-flex flex-wrap items-center gap-x-2 gap-y-1"
|
||
data-testid="recovery-retry-progress"
|
||
data-recovery-lane={lineage.lane}
|
||
data-recovery-attempt={lineage.attempt}
|
||
data-recovery-max-attempts={lineage.maxAttempts ?? undefined}
|
||
>
|
||
<AttemptMeter lineage={lineage} />
|
||
<span>{attemptLabel ?? "Attempts not bounded"}</span>
|
||
{lineage.liveRunId ? (
|
||
<span
|
||
className={RETRY_PILL_CLASS}
|
||
title={formatTimeAbsolute(lineage.nextRetryAt) ?? undefined}
|
||
data-testid="recovery-next-retry"
|
||
>
|
||
Attempt running now
|
||
</span>
|
||
) : lineage.retryExpired ? (
|
||
// The due time is stated plainly as missed. Rendering it as "Next try 5m
|
||
// ago" is what made an abandoned lane read as healthy recovery.
|
||
<span
|
||
className={cn(RETRY_PILL_CLASS, "border-destructive/50 bg-destructive/10 text-destructive")}
|
||
title={formatTimeAbsolute(lineage.nextRetryAt) ?? undefined}
|
||
data-testid="recovery-next-retry"
|
||
data-recovery-retry-expired="true"
|
||
>
|
||
{retryOffset ? `Retry missed ${retryOffset}` : "Retry missed"}
|
||
</span>
|
||
) : retryOffset ? (
|
||
<span
|
||
className={RETRY_PILL_CLASS}
|
||
title={formatTimeAbsolute(lineage.nextRetryAt) ?? undefined}
|
||
data-testid="recovery-next-retry"
|
||
>
|
||
{retryOffset === "now" ? "Next try now" : `Next try ${retryOffset}`}
|
||
</span>
|
||
) : lineage.exhausted ? (
|
||
<span className={RETRY_PILL_CLASS} data-testid="recovery-next-retry">
|
||
Automatic retries used up
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</MetadataRow>
|
||
{lineage.lane !== "source_owner" && lineage.sourceMaxAttempts !== null ? (
|
||
<MetadataRow label="Owner retries">
|
||
<span data-testid="recovery-source-attempts">
|
||
The original owner used {lineage.sourceAttempt ?? lineage.sourceMaxAttempts} of{" "}
|
||
{lineage.sourceMaxAttempts} automatic attempts.
|
||
</span>
|
||
</MetadataRow>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<MetadataRow label="Owner">
|
||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||
{action.ownerType === "agent" && action.ownerAgentId ? (
|
||
<>
|
||
<span className="text-muted-foreground">Recovery:</span>
|
||
<AgentLink agentId={action.ownerAgentId} agentMap={agentMap} />
|
||
</>
|
||
) : action.ownerType === "board" ? (
|
||
<span className="font-medium">Board</span>
|
||
) : action.ownerType === "user" && action.ownerUserId ? (
|
||
<span className="font-medium">user {action.ownerUserId.slice(0, 6)}</span>
|
||
) : action.ownerType === "system" ? (
|
||
<span className="font-medium">System</span>
|
||
) : (
|
||
<span className="text-muted-foreground">unassigned — pick one to wake them</span>
|
||
)}
|
||
{action.returnOwnerAgentId ? (
|
||
<>
|
||
<span className="text-muted-foreground">→ Returns to:</span>
|
||
<AgentLink agentId={action.returnOwnerAgentId} agentMap={agentMap} />
|
||
</>
|
||
) : null}
|
||
</span>
|
||
</MetadataRow>
|
||
)}
|
||
<MetadataRow label="Source run">
|
||
<RunChip runId={sourceRunId} agentId={action.previousOwnerAgentId} />
|
||
</MetadataRow>
|
||
{correctiveRunId ? (
|
||
<MetadataRow label="Corrective run">
|
||
<RunChip runId={correctiveRunId} agentId={action.previousOwnerAgentId} />
|
||
</MetadataRow>
|
||
) : null}
|
||
<MetadataRow label="Evidence">
|
||
{evidenceSummary ? (
|
||
evidenceSummary.isCode ? (
|
||
<span className="break-words font-mono text-(length:--text-micro) text-foreground/80">
|
||
{evidenceSummary.text}
|
||
</span>
|
||
) : (
|
||
<span className="text-xs leading-5 text-foreground/80">{evidenceSummary.text}</span>
|
||
)
|
||
) : (
|
||
<MissingValue />
|
||
)}
|
||
</MetadataRow>
|
||
<MetadataRow label="Next action">
|
||
{action.nextAction ? <span>{action.nextAction}</span> : <MissingValue />}
|
||
</MetadataRow>
|
||
<MetadataRow label="Follow-up">
|
||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||
{wakeSummary ? <span>{wakeSummary}</span> : <MissingValue />}
|
||
{showAttempt ? (
|
||
<span className="rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-(length:--text-micro) text-muted-foreground">
|
||
attempt {action.attemptCount} of {action.maxAttempts}
|
||
</span>
|
||
) : null}
|
||
{showTimeoutInline ? (
|
||
<span className="rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-(length:--text-micro) text-muted-foreground">
|
||
Times out {formatTimeShort(action.timeoutAt) ?? "soon"}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</MetadataRow>
|
||
{cardState === "resolved" && action.outcome ? (
|
||
<MetadataRow label="Resolution">
|
||
<span className={cn("font-medium", tone.labelClass)}>
|
||
Resolved as {OUTCOME_LABEL[action.outcome]}
|
||
{action.resolvedAt ? ` · ${formatTimeShort(action.resolvedAt) ?? ""}` : ""}
|
||
</span>
|
||
</MetadataRow>
|
||
) : null}
|
||
</dl>
|
||
)}
|
||
{divergence ? <DivergenceDiagnosis divergence={divergence} dividerClass={tone.divider} /> : null}
|
||
{showFooter ? (
|
||
<div className={cn("flex flex-wrap items-center gap-2 border-t px-3 py-2.5 sm:px-4", tone.divider)}>
|
||
{showResolveActions ? (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="default"
|
||
data-testid="recovery-action-resolve-trigger"
|
||
aria-label="Resolve recovery"
|
||
>
|
||
Resolve…
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align="start"
|
||
sideOffset={6}
|
||
className="w-72 p-1.5"
|
||
>
|
||
<div className="px-2 py-1 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
|
||
Resolve recovery
|
||
</div>
|
||
<div className="flex flex-col">
|
||
{visibleResolveOptions.map((option) => (
|
||
<button
|
||
key={option.outcome}
|
||
type="button"
|
||
onClick={() => onResolve?.(option.outcome)}
|
||
className={cn(
|
||
"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||
option.destructive ? "text-destructive" : null,
|
||
)}
|
||
>
|
||
<span className="font-medium leading-5">{option.label}</span>
|
||
<span className="text-(length:--text-micro) leading-4 text-muted-foreground">{option.description}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
) : null}
|
||
{showReconcileForward ? (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="default"
|
||
disabled={reconcilePending}
|
||
data-testid="recovery-action-reconcile-forward"
|
||
onClick={() => onReconcileForward?.()}
|
||
>
|
||
{reconcilePending ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||
) : (
|
||
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
|
||
)}
|
||
Reconcile forward & continue
|
||
</Button>
|
||
) : null}
|
||
{showRepairAction && divergence ? (
|
||
<RepairWorkspace
|
||
divergence={divergence}
|
||
pending={quarantineRestorePending}
|
||
disabled={repairContention !== null}
|
||
disabledReason={repairDisabledReason}
|
||
onConfirm={() => onQuarantineRestore?.()}
|
||
/>
|
||
) : null}
|
||
{showReissueAction && divergence && reissueBaseRef ? (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={reissueRecommended ? "default" : "outline"}
|
||
disabled={reissuePending}
|
||
data-testid="recovery-action-reissue-trigger"
|
||
data-recommended={reissueRecommended ? "true" : undefined}
|
||
>
|
||
{reissuePending ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||
) : (
|
||
<GitBranchPlus className="h-3.5 w-3.5" aria-hidden />
|
||
)}
|
||
Re-issue on isolated workspace
|
||
{reissueRecommended ? (
|
||
<span
|
||
data-testid="recovery-reissue-recommended"
|
||
className="ml-1 rounded-sm bg-background/25 px-1.5 py-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-label)"
|
||
>
|
||
Recommended
|
||
</span>
|
||
) : null}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="start" sideOffset={6} className="w-80 space-y-3 p-3">
|
||
<div className="space-y-1">
|
||
<div className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
|
||
Re-issue on isolated workspace
|
||
</div>
|
||
<p className="text-xs leading-5 text-muted-foreground">
|
||
Creates a fresh copy of this task on an isolated git worktree based on the live
|
||
branch. Your current workspace and its commits are left untouched.
|
||
</p>
|
||
</div>
|
||
<dl className="space-y-1 rounded-md border border-border/70 bg-muted/30 px-2.5 py-2 text-(length:--text-micro)">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="text-muted-foreground">Base ref</dt>
|
||
<dd className="min-w-0 truncate font-mono text-foreground/90">{reissueBaseRef}</dd>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="text-muted-foreground">Recorded</dt>
|
||
<dd className="min-w-0 truncate font-mono text-foreground/80">
|
||
{divergence.expectedBranch ?? "—"}
|
||
</dd>
|
||
</div>
|
||
{reissueVerdictBadge ? (
|
||
<div className="flex items-center justify-between gap-2">
|
||
<dt className="text-muted-foreground">Ancestry</dt>
|
||
<dd className="font-medium">{reissueVerdictBadge.label}</dd>
|
||
</div>
|
||
) : null}
|
||
</dl>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
className="w-full"
|
||
disabled={reissuePending}
|
||
data-testid="recovery-action-reissue-confirm"
|
||
onClick={() =>
|
||
onReissueIsolated?.({
|
||
baseRef: reissueBaseRef,
|
||
liveBranch: divergence.liveBranch,
|
||
liveHeadSha: divergence.liveHeadSha,
|
||
expectedBranch: divergence.expectedBranch,
|
||
})
|
||
}
|
||
>
|
||
{reissuePending ? "Creating…" : "Create isolated re-issue"}
|
||
</Button>
|
||
</PopoverContent>
|
||
</Popover>
|
||
) : null}
|
||
{showBreakGlass && divergence ? (
|
||
<BreakGlassOverride
|
||
divergence={divergence}
|
||
pending={reconcilePending}
|
||
onConfirm={(reason) => onBreakGlassOverride?.(reason)}
|
||
/>
|
||
) : null}
|
||
{showResolveActions ? (
|
||
cardState === "observe_only" ? (
|
||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||
Recovery is observing without interrupting the live run.
|
||
</span>
|
||
) : (
|
||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||
The card stays open until an explicit decision is recorded.
|
||
</span>
|
||
)
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export type { IssueRecoveryActionStatus };
|
||
|
||
export default IssueRecoveryActionCard;
|
||
|
||
/** Separate original-host confirmations: the opaque authority is supplied by
|
||
* the server snapshot, never entered or edited by the operator. */
|
||
export function NativeControllerContainmentRecovery({ issue, canManage, onRecorded }: {
|
||
issue: Pick<Issue, "id" | "status" | "activeRecoveryAction">; canManage: boolean; onRecorded: () => void;
|
||
}) {
|
||
const action = issue.activeRecoveryAction;
|
||
const [controllerStopped, setControllerStopped] = useState(false);
|
||
const [providerStopped, setProviderStopped] = useState(false);
|
||
const [outcome, setOutcome] = useState<"" | "completed" | "not_performed" | "mixed">("");
|
||
const [evidence, setEvidence] = useState("");
|
||
const authority = action?.evidence?.controllerAuthority;
|
||
const digest = authority && typeof authority === "object" && !Array.isArray(authority)
|
||
? (authority as Record<string, unknown>).digest : undefined;
|
||
const runId = action?.evidence?.runId;
|
||
const submit = useMutation({ mutationFn: async () => {
|
||
if (!action || typeof runId !== "string" || typeof digest !== "string" || !outcome
|
||
|| !controllerStopped || !providerStopped) throw new Error("Confirm both original-host stops and review the action outcomes.");
|
||
return issuesApi.resolveRecoveryAction(issue.id, { actionId: action.id, outcome: "restored", sourceIssueStatus: issue.status,
|
||
executionReconciliation: { runId, controllerStopped: true, providerStopped: true,
|
||
controllerAuthorityDigest: digest, actionOutcome: outcome, outcomeEvidence: evidence.trim() } });
|
||
}, onSuccess: onRecorded });
|
||
if (!action || action.kind !== "active_run_watchdog" || action.ownerType !== "board"
|
||
|| action.cause !== "native_restart_recovery_blocked" || action.evidence?.reason !== "controller_process_space_unverified"
|
||
|| !["active", "escalated"].includes(action.status) || typeof digest !== "string") return null;
|
||
return <section aria-label="Original controller recovery">
|
||
<h3>Verify the original execution stopped</h3>
|
||
<p>Paperclip cannot compare process IDs across hosts or kernel sessions. Stop both the controller and provider on the original host before recording evidence. The saved lease must also expire and environment/workspace cleanup must finish. Recording containment does not retry the task.</p>
|
||
{canManage ? <>
|
||
<Label><Checkbox checked={controllerStopped} onCheckedChange={value => setControllerStopped(value === true)} /> I verified the original controller stopped on its original host.</Label>
|
||
<Label><Checkbox checked={providerStopped} onCheckedChange={value => setProviderStopped(value === true)} /> I verified the original provider and its tools stopped on their original host.</Label>
|
||
<Label>Reviewed external action outcomes
|
||
<NativeSelect value={outcome} onChange={event => {
|
||
const value = event.target.value;
|
||
if (value === "completed" || value === "not_performed" || value === "mixed" || value === "") setOutcome(value);
|
||
}}>
|
||
<option value="">Select only after reviewing the effects</option>
|
||
<option value="completed">All completed</option>
|
||
<option value="not_performed">None performed</option>
|
||
<option value="mixed">Some completed and some not performed</option>
|
||
</NativeSelect>
|
||
</Label>
|
||
<Label>Original-host containment evidence and actions that must not be repeated
|
||
<Textarea maxLength={12000} value={evidence} onChange={event => setEvidence(event.target.value)} />
|
||
</Label>
|
||
<p>If any action outcome is still unknown, keep this hold. After evidence and cleanup settle, use the task’s ordinary Retry action separately.</p>
|
||
<Button disabled={submit.isPending || !controllerStopped || !providerStopped || !outcome || evidence.trim().length < 20}
|
||
onClick={() => submit.mutate()}>{submit.isPending ? "Recording…" : "Record containment"}</Button>
|
||
{submit.error && <p role="alert">{describeError(submit.error).body}</p>}
|
||
</> : <p>A company owner, company administrator, or instance administrator must review this original-host execution.</p>}
|
||
</section>;
|
||
}
|