From 696c694a58c47781b27cddf1fa839f65bd2cfdb0 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 6 Jul 2026 23:02:49 -0700 Subject: [PATCH] feat(ui): recovery-card divergence diagnosis + one-click isolated re-issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents execute on isolated workspaces (git worktrees), each pinned to a specific branch and commit at checkout time > - When an agent's live checkout diverges from the recorded workspace branch — either through a branch rename, a stale worktree, or a concurrent git operation — Paperclip detects the mismatch and surfaces a recovery card to the operator > - But the existing recovery card showed a generic error with no diagnostic context: it didn't display *which* branch was expected vs. which was checked out, the commit SHAs involved, or whether the branches share ancestry > - Without that information operators cannot diagnose the root cause, and the only recovery path was fully manual re-issue > - This pull request extends `IssueRecoveryActionCard` for `workspace_validation` / `git_worktree_branch_incoherence` recovery kinds to render a divergence-diagnosis panel (expected branch, live branch, short SHAs, ancestry-verdict badge, plain-language reason) and adds a confirm-gated "Re-issue on isolated workspace" action that creates a new task with `executionWorkspacePreference: isolated_workspace` so the re-issued run cannot trip the same branch-mismatch gate > - The benefit is that operators can immediately see *why* a workspace was declined and recover with a single click instead of having to manually reconstruct the task ## Linked Issues or Issue Description Refs #4757 (heartbeat re-wake doesn't reconcile working-tree HEAD against ticket's expected branch — this PR surfaces the resulting divergence to the operator and provides a one-click isolated re-issue path) Refs #8460 (workspace_validation_failed local-only project workspaces — this PR extends the recovery card UI for this case) **Subsystem affected:** ui/ — React + Vite board UI **Problem or motivation:** When Paperclip records a workspace branch for an agent run and the live checkout disagrees (diverged HEAD, renamed branch, stale worktree), the issue recovery card surfaces a generic `workspace_validation` error. The operator sees "run declined" but has no visibility into the expected vs. actual branch, the relevant commit SHAs, or whether the branches even share ancestry. There is no one-click path to re-issue the task on a clean isolated workspace — the operator must manually reconstruct the task from scratch. **Proposed solution:** Extend `IssueRecoveryActionCard` to: 1. Render a divergence-diagnosis panel from the `recoveryEvidence` field: expected branch, live branch, short SHAs for both, an ancestry-verdict badge (`forward-only` / `diverged` / `ancestry unknown`), and the server's `plainLanguageReason`. 2. Add Action 3 "Re-issue on isolated workspace" — a confirm-gated button that calls `issuesApi.create` with `executionWorkspacePreference: isolated_workspace` and `workspaceStrategy.baseRef` set to the live branch (SHA fallback when detached). The current workspace is never mutated. 3. Wire `onReissueIsolated` / `reissuePending` through `IssueChatThread` → `IssueDetail` so the operator sees an immediate success toast and is navigated to the new task. **Alternatives considered:** Showing divergence details only in a tooltip (rejected — too easy to miss). Providing a "force-reset the workspace" action (rejected — destructive, no audit trail, doesn't fix stale-branch root cause). Isolated re-issue via isolated workspace was the clearest safe path. ## What Changed - `IssueRecoveryActionCard.tsx` — Added `DiagnosisPanel` sub-component rendered for `workspace_validation` / `git_worktree_branch_incoherence` recovery kinds: displays expected vs. live branch, short SHAs, ancestry-verdict badge, and plain-language reason. Added Action 3 confirm-popover with `onReissueIsolated` callback and `reissuePending` loading state. Kept existing Action 1 and Action 2 unchanged. - `IssueChatThread.tsx` — Threaded `onReissueIsolated` and `reissuePending` props down to `IssueRecoveryActionCard`. - `IssueDetail.tsx` — Implemented `handleReissueIsolated`: calls `issuesApi.create` with `executionWorkspacePreference: isolated_workspace` + `workspaceStrategy.baseRef` derived from live branch / SHA; shows a success toast and navigates to the new task on completion. - `IssueRecoveryActionCard.test.tsx` — Added 19 unit tests covering diagnosis-panel rendering, verdict label rendering, base-ref derivation (branch-first then detached-HEAD SHA fallback), and action gating. ## Verification ```bash # Unit tests — 19/19 pass pnpm vitest run ui/src/components/IssueRecoveryActionCard.test.tsx # Typecheck — 0 new errors pnpm typecheck ``` Manual browser validation deferred to QA — see Risks. ## Risks - **Re-issue creates a new task** — the original task remains unchanged. This is intentional (safe default), but operators should be aware both tasks exist after re-issue. - **Base-ref derivation falls back to the live HEAD SHA when detached.** SHA-based worktrees are valid for isolated re-issue but may surprise operators expecting a branch name. - **Browser-level end-to-end validation not included here.** Toast, navigation, and full create-flow are covered by integration QA in a follow-up pass. - **Low overall risk** — no new endpoints, no data mutations on existing records, no PII or telemetry changes. Composes the existing `issuesApi.create` endpoint; all new behavior is additive. ## Model Used Claude Sonnet 4.6 (`claude-sonnet-4-6`) — Anthropic. 200k context window, tool use, code execution. Extended thinking not used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Paperclip --- ui/src/components/IssueChatThread.tsx | 12 +- .../IssueRecoveryActionCard.test.tsx | 121 ++++++ ui/src/components/IssueRecoveryActionCard.tsx | 375 +++++++++++++++--- ui/src/pages/IssueDetail.tsx | 73 ++++ 4 files changed, 531 insertions(+), 50 deletions(-) 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}