feat(ui): recovery-card divergence diagnosis + one-click isolated re-issue
## 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 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
9d5b0e3c57
commit
696c694a58
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -257,3 +257,124 @@ describe("IssueRecoveryActionCard", () => {
|
|||
expect(onResolve).toHaveBeenCalledWith("false_positive_done");
|
||||
});
|
||||
});
|
||||
|
||||
function buildWorkspaceValidationAction(
|
||||
overrides: {
|
||||
action?: Partial<IssueRecoveryAction>;
|
||||
provenance?: Record<string, unknown>;
|
||||
workspaceValidation?: Record<string, unknown>;
|
||||
} = {},
|
||||
): 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(<IssueRecoveryActionCard action={buildWorkspaceValidationAction()} />);
|
||||
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(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildWorkspaceValidationAction({ provenance: { ancestryVerdict: "ancestor" } })}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildWorkspaceValidationAction({
|
||||
workspaceValidation: { reason: "workspace_link_missing", provenance: undefined },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildWorkspaceValidationAction()}
|
||||
onReissueIsolated={onReissueIsolated}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildWorkspaceValidationAction({ workspaceValidation: { actualBranch: null } })}
|
||||
onReissueIsolated={onReissueIsolated}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<IssueRecoveryActionCard action={buildAction()} onReissueIsolated={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<IssueRecoveryActionCard
|
||||
action={buildWorkspaceValidationAction()}
|
||||
onReissueIsolated={() => {}}
|
||||
reissuePending
|
||||
/>,
|
||||
);
|
||||
const trigger = node.querySelector<HTMLButtonElement>("[data-testid='recovery-action-reissue-trigger']");
|
||||
expect(trigger?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, Agent>;
|
||||
|
|
@ -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<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 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 (
|
||||
<div className="min-w-0 rounded-md border border-border/70 bg-background/60 px-2.5 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.1em] 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-[12px] text-foreground/90">{branch}</code>
|
||||
) : (
|
||||
<span className="text-[12px] italic text-muted-foreground">detached / unknown</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 font-mono text-[11px] 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-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Divergence diagnosis
|
||||
</span>
|
||||
<span
|
||||
data-testid="recovery-ancestry-verdict"
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]",
|
||||
badge.className,
|
||||
)}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
</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-[12px] leading-5 text-foreground/80">{divergence.plainLanguageReason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
|
|
@ -489,56 +695,127 @@ export function IssueRecoveryActionCard({
|
|||
</MetadataRow>
|
||||
) : null}
|
||||
</dl>
|
||||
{showResolveActions ? (
|
||||
{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)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
data-testid="recovery-action-resolve-trigger"
|
||||
aria-label="Resolve recovery"
|
||||
{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"
|
||||
>
|
||||
Resolve…
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-72 p-1.5"
|
||||
>
|
||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] 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-[11px] leading-4 text-muted-foreground">{option.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{cardState === "observe_only" ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Recovery is observing without interrupting the live run.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
The card stays open until an explicit decision is recorded.
|
||||
</span>
|
||||
)}
|
||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] 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-[11px] leading-4 text-muted-foreground">{option.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
{showReissueAction && divergence && reissueBaseRef ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={reissuePending}
|
||||
data-testid="recovery-action-reissue-trigger"
|
||||
>
|
||||
{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
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" sideOffset={6} className="w-80 space-y-3 p-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Re-issue on isolated workspace
|
||||
</div>
|
||||
<p className="text-[12px] 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-[11px]">
|
||||
<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}
|
||||
{showResolveActions ? (
|
||||
cardState === "observe_only" ? (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Recovery is observing without interrupting the live run.
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
The card stays open until an explicit decision is recorded.
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue