diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 6aa61cf760..a394ea15dc 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -169,7 +169,11 @@ import { Textarea } from "@/components/ui/textarea"; import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"; import { IssueBlockedNotice } from "./IssueBlockedNotice"; import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice"; -import { IssueRecoveryActionCard, type RecoveryResolveOutcome } from "./IssueRecoveryActionCard"; +import { + IssueRecoveryActionCard, + type RecoveryReissueRequest, + type RecoveryResolveOutcome, +} from "./IssueRecoveryActionCard"; import { SourceTrustBadge } from "./SourceTrustBadge"; interface IssueChatMessageContext { @@ -421,6 +425,8 @@ interface IssueChatThreadProps { scheduledRetry?: IssueScheduledRetry | null; recoveryAction?: IssueRecoveryAction | null; onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void; + onReissueIsolatedRecoveryAction?: (request: RecoveryReissueRequest) => void; + reissueIsolatedRecoveryActionPending?: boolean; canFalsePositiveRecoveryAction?: boolean; legacyRecoverySourceIssue?: { identifier: string | null; @@ -4149,6 +4155,8 @@ export function IssueChatThread({ scheduledRetry = null, recoveryAction = null, onResolveRecoveryAction, + onReissueIsolatedRecoveryAction, + reissueIsolatedRecoveryActionPending = false, canFalsePositiveRecoveryAction = false, legacyRecoverySourceIssue = null, companyId, @@ -4862,6 +4870,8 @@ export function IssueChatThread({ action={recoveryAction} agentMap={agentMap} onResolve={onResolveRecoveryAction} + onReissueIsolated={onReissueIsolatedRecoveryAction} + reissuePending={reissueIsolatedRecoveryActionPending} canFalsePositive={canFalsePositiveRecoveryAction} /> ) : null} diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index a82a717b7c..9f110e2058 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -257,3 +257,124 @@ describe("IssueRecoveryActionCard", () => { expect(onResolve).toHaveBeenCalledWith("false_positive_done"); }); }); + +function buildWorkspaceValidationAction( + overrides: { + action?: Partial; + provenance?: Record; + workspaceValidation?: Record; + } = {}, +): IssueRecoveryAction { + const provenance = { + expectedHeadSha: "aaaaaaaaaaaa11112222", + actualHeadSha: "bbbbbbbbbbbb33334444", + ancestryVerdict: "diverged", + plainLanguageReason: + 'The recorded branch "PAP-522-recorded" is not an ancestor of the checked-out branch "nleach/PAP-1405-live", so Paperclip cannot prove a forward-only reconciliation.', + ...overrides.provenance, + }; + return buildAction({ + kind: "workspace_validation", + cause: "workspace_validation_failed", + evidence: { + workspaceValidation: { + reason: "git_worktree_branch_incoherence", + expectedBranch: "PAP-522-recorded", + actualBranch: "nleach/PAP-1405-live", + cleanliness: "clean", + provenance, + ...overrides.workspaceValidation, + }, + }, + ...overrides.action, + }); +} + +describe("IssueRecoveryActionCard workspace_validation divergence", () => { + it("renders the divergence diagnosis with branches, shas, verdict and plain-language reason", () => { + const node = render(); + const diagnosis = node.querySelector("[data-testid='recovery-divergence-diagnosis']"); + expect(diagnosis).not.toBeNull(); + const text = diagnosis?.textContent ?? ""; + expect(text).toContain("PAP-522-recorded"); + expect(text).toContain("nleach/PAP-1405-live"); + // shortened shas (10 chars) + expect(text).toContain("aaaaaaaaaa"); + expect(text).toContain("bbbbbbbbbb"); + expect(text).toContain("cannot prove a forward-only reconciliation"); + expect(node.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toContain("Diverged"); + }); + + it("labels an ancestor verdict as forward-only", () => { + const node = render( + , + ); + expect(node.querySelector("[data-testid='recovery-ancestry-verdict']")?.textContent).toContain("Forward-only"); + }); + + it("does not render a divergence diagnosis for non-incoherence workspace failures", () => { + const node = render( + , + ); + expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).toBeNull(); + }); + + it("offers the re-issue action and passes the live branch as the base ref", () => { + const onReissueIsolated = vi.fn(); + const node = render( + , + ); + click(node.querySelector("[data-testid='recovery-action-reissue-trigger']")); + expect(document.body.textContent).toContain("Re-issue on isolated workspace"); + click(document.body.querySelector("[data-testid='recovery-action-reissue-confirm']")); + expect(onReissueIsolated).toHaveBeenCalledWith({ + baseRef: "nleach/PAP-1405-live", + liveBranch: "nleach/PAP-1405-live", + liveHeadSha: "bbbbbbbbbbbb33334444", + expectedBranch: "PAP-522-recorded", + }); + }); + + it("falls back to the live HEAD sha as base ref when the branch is detached", () => { + const onReissueIsolated = vi.fn(); + const node = render( + , + ); + click(node.querySelector("[data-testid='recovery-action-reissue-trigger']")); + click(document.body.querySelector("[data-testid='recovery-action-reissue-confirm']")); + expect(onReissueIsolated).toHaveBeenCalledWith( + expect.objectContaining({ baseRef: "bbbbbbbbbbbb33334444", liveBranch: null }), + ); + }); + + it("does not offer the re-issue action for non-workspace kinds", () => { + const node = render( + {}} />, + ); + expect(node.querySelector("[data-testid='recovery-action-reissue-trigger']")).toBeNull(); + }); + + it("disables the re-issue action while a re-issue is pending", () => { + const node = render( + {}} + reissuePending + />, + ); + const trigger = node.querySelector("[data-testid='recovery-action-reissue-trigger']"); + expect(trigger?.disabled).toBe(true); + }); +}); diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index d236b92f1e..51ea6aabad 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -1,12 +1,22 @@ import { useMemo } from "react"; import type { Agent, + GitWorktreeBranchAncestryVerdict, IssueRecoveryAction, IssueRecoveryActionKind, IssueRecoveryActionOutcome, IssueRecoveryActionStatus, } from "@paperclipai/shared"; -import { Eye, OctagonAlert, RefreshCw, Sparkles, TriangleAlert } from "lucide-react"; +import { + Eye, + GitBranch, + GitBranchPlus, + Loader2, + OctagonAlert, + RefreshCw, + Sparkles, + TriangleAlert, +} from "lucide-react"; import { Link } from "@/lib/router"; import { Button } from "@/components/ui/button"; import { @@ -31,6 +41,18 @@ export type RecoveryResolveOutcome = | "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; @@ -38,6 +60,14 @@ export interface IssueRecoveryActionCardProps { 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; /** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */ canFalsePositive?: boolean; className?: string; @@ -167,6 +197,169 @@ function readEvidenceRunId(action: IssueRecoveryAction, key: "sourceRunId" | "co return next; } +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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 WorkspaceDivergence { + expectedBranch: string | null; + liveBranch: string | null; + expectedHeadSha: string | null; + liveHeadSha: string | null; + ancestryVerdict: GitWorktreeBranchAncestryVerdict | null; + plainLanguageReason: string | null; + cleanliness: "clean" | "dirty" | "unknown" | null; + /** Ref a re-issue should base off — the live branch when known, else the live HEAD sha. */ + reissueBaseRef: string | 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; + return { + expectedBranch, + liveBranch, + expectedHeadSha, + liveHeadSha, + ancestryVerdict: asAncestryVerdict(provenance.ancestryVerdict), + plainLanguageReason: asNonEmptyString(provenance.plainLanguageReason), + cleanliness, + 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 ( +
+
+ {label} +
+
+ + {branch ? ( + {branch} + ) : ( + detached / unknown + )} +
+
+ {shortSha ? `@ ${shortSha}` : "@ —"} +
+
+ ); +} + +function DivergenceDiagnosis({ + divergence, + dividerClass, +}: { + divergence: WorkspaceDivergence; + dividerClass: string; +}) { + const badge = ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"]; + return ( +
+
+ + Divergence diagnosis + + + {badge.label} + +
+
+ + +
+ {divergence.plainLanguageReason ? ( +

{divergence.plainLanguageReason}

+ ) : null} +
+ ); +} + function readWakePolicySummary(action: IssueRecoveryAction): string | null { const policy = action.wakePolicy; if (!policy) return null; @@ -336,12 +529,15 @@ export function IssueRecoveryActionCard({ agentMap, forcedState, onResolve, + onReissueIsolated, + reissuePending = false, canFalsePositive = false, className, }: IssueRecoveryActionCardProps) { const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action); const tone = STATE_TONE[cardState]; const ToneIcon = tone.Icon; + const divergence = useMemo(() => readWorkspaceDivergence(action), [action]); const headline = useMemo(() => { if (cardState === "resolved" && action.outcome) { @@ -380,6 +576,16 @@ export function IssueRecoveryActionCard({ if (option.boardOnly && !canFalsePositive) return false; return true; }); + const reissueBaseRef = divergence?.reissueBaseRef ?? null; + const showReissueAction = + onReissueIsolated !== undefined && + cardState !== "resolved" && + divergence !== null && + reissueBaseRef !== null; + const reissueVerdictBadge = divergence + ? ANCESTRY_BADGE[divergence.ancestryVerdict ?? "unknown"] + : null; + const showFooter = showResolveActions || showReissueAction; return (
) : null} - {showResolveActions ? ( + {divergence ? : null} + {showFooter ? (
- - - + + - Resolve… - - - -
- Resolve recovery -
-
- {visibleResolveOptions.map((option) => ( - - ))} -
-
-
- {cardState === "observe_only" ? ( - - Recovery is observing without interrupting the live run. - - ) : ( - - The card stays open until an explicit decision is recorded. - - )} +
+ Resolve recovery +
+
+ {visibleResolveOptions.map((option) => ( + + ))} +
+ + + ) : null} + {showReissueAction && divergence && reissueBaseRef ? ( + + + + + +
+
+ Re-issue on isolated workspace +
+

+ 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. +

+
+
+
+
Base ref
+
{reissueBaseRef}
+
+
+
Recorded
+
+ {divergence.expectedBranch ?? "—"} +
+
+ {reissueVerdictBadge ? ( +
+
Ancestry
+
{reissueVerdictBadge.label}
+
+ ) : null} +
+ +
+
+ ) : null} + {showResolveActions ? ( + cardState === "observe_only" ? ( + + Recovery is observing without interrupting the live run. + + ) : ( + + The card stays open until an explicit decision is recorded. + + ) + ) : null}
) : null}
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index c4f44c6517..6ffd4f6c3f 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -827,6 +827,8 @@ type IssueDetailChatTabProps = { scheduledRetry: Issue["scheduledRetry"] | null; recoveryAction: Issue["activeRecoveryAction"]; onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void; + onReissueIsolatedRecoveryAction?: (request: import("../components/IssueRecoveryActionCard").RecoveryReissueRequest) => void; + reissueIsolatedRecoveryActionPending?: boolean; canFalsePositiveRecoveryAction?: boolean; legacyRecoverySourceIssue?: { identifier: string | null; @@ -904,6 +906,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ scheduledRetry, recoveryAction, onResolveRecoveryAction, + onReissueIsolatedRecoveryAction, + reissueIsolatedRecoveryActionPending, canFalsePositiveRecoveryAction, legacyRecoverySourceIssue, comments, @@ -1121,6 +1125,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ scheduledRetry={scheduledRetry} recoveryAction={recoveryAction ?? null} onResolveRecoveryAction={onResolveRecoveryAction} + onReissueIsolatedRecoveryAction={onReissueIsolatedRecoveryAction} + reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryActionPending} canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction} legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null} companyId={companyId} @@ -3534,6 +3540,71 @@ export function IssueDetail() { [activeRecoveryActionId, resolveRecoveryAction.mutateAsync], ); + // Action 3 (workspace_validation): one-click re-issue of the stalled task on a fresh isolated + // git worktree based on the live (diverged) branch. Composes the existing safe issue-creation + // endpoint — it never mutates the current workspace, so the operator's commits are preserved. + const reissueIsolatedRecoveryAction = useMutation({ + mutationFn: async ( + request: import("../components/IssueRecoveryActionCard").RecoveryReissueRequest, + ) => { + if (!issue) throw new Error("Task is not loaded yet."); + const sourceLabel = issue.identifier ?? "the stalled task"; + const descriptionLines = [ + `Re-issued from ${sourceLabel} on an isolated git worktree after a workspace branch divergence.`, + "", + `- Base ref (live branch): \`${request.baseRef}\``, + ...(request.expectedBranch ? [`- Recorded branch: \`${request.expectedBranch}\``] : []), + "", + "---", + "", + issue.description ?? "", + ]; + return issuesApi.create(issue.companyId, { + title: `Re-issue (isolated): ${issue.title ?? sourceLabel}`, + description: descriptionLines.join("\n"), + priority: issue.priority, + projectId: issue.projectId ?? null, + parentId: issue.parentId ?? null, + assigneeAgentId: + issue.activeRecoveryAction?.returnOwnerAgentId ?? + issue.activeRecoveryAction?.previousOwnerAgentId ?? + issue.assigneeAgentId ?? + null, + executionWorkspacePreference: "isolated_workspace", + executionWorkspaceSettings: { + mode: "isolated_workspace", + workspaceStrategy: { type: "git_worktree", baseRef: request.baseRef }, + }, + }); + }, + onSuccess: (created) => { + invalidateIssueCollections(); + pushToast({ + title: "Isolated re-issue created", + body: created.identifier + ? `${created.identifier} will run on a fresh isolated workspace.` + : "A fresh isolated re-issue was created.", + tone: "success", + }); + if (created.identifier) { + navigate(createIssueDetailPath(created.identifier)); + } + }, + onError: (err) => { + pushToast({ + title: "Re-issue failed", + body: err instanceof Error ? err.message : "Unable to create an isolated re-issue.", + tone: "error", + }); + }, + }); + const handleReissueIsolatedRecoveryAction = useCallback( + (request: import("../components/IssueRecoveryActionCard").RecoveryReissueRequest) => { + void reissueIsolatedRecoveryAction.mutateAsync(request); + }, + [reissueIsolatedRecoveryAction.mutateAsync], + ); + const treePreviewAffectedIssues = useMemo( () => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped), [treeControlPreview], @@ -4425,6 +4496,8 @@ export function IssueDetail() { scheduledRetry={issue.scheduledRetry ?? null} recoveryAction={issue.activeRecoveryAction ?? null} onResolveRecoveryAction={handleResolveRecoveryAction} + onReissueIsolatedRecoveryAction={handleReissueIsolatedRecoveryAction} + reissueIsolatedRecoveryActionPending={reissueIsolatedRecoveryAction.isPending} canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction} legacyRecoverySourceIssue={legacyRecoverySourceIssue} comments={threadComments}