Files
PaperClipAI/ui/src/components/RunWorkspaceRecoverySurface.test.tsx
T
f3ca4d24bc fix: repair dirty/foreign-branch execution worktrees (#9297)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Execution workspaces are the bridge between Paperclip's control
plane and a local agent's checked-out repository state.
> - When a workspace is restored after a failed or interrupted run, the
recorded branch can disagree with the branch currently checked out on
disk.
> - A clean branch mismatch can be reconciled safely, but a dirty
mismatch needs a lossless path that does not discard uncommitted agent
work.
> - This pull request adds a quarantine-and-restore path that saves
dirty work to a rescue branch, restores the recorded branch, and exposes
the repair from the board UI and run page.
> - The benefit is that operators can recover wedged execution
workspaces without losing work or moving another live branch
unexpectedly.

## Linked Issues or Issue Description

No public GitHub issue exists for this workspace-recovery failure, so
this PR includes the bug report inline.

**What happened**

A git worktree-backed execution workspace could become wedged when
Paperclip expected one branch but found a different checked-out branch
with dirty tracked or untracked files. The existing safe repair path
refused the restore, leaving the source task blocked with no lossless
one-click recovery path.

**Expected behavior**

Paperclip should preserve dirty work before restoring the recorded
workspace branch. If another live workspace claims the checked-out
branch, or an attached runtime service is active, the repair should
refuse with clear operator-facing evidence instead of risking work loss
or file contention.

**Steps to reproduce**

Create a git worktree execution workspace whose persisted branch name
differs from the checked-out branch, add dirty tracked or untracked
files in that worktree, then trigger workspace validation or use the
branch reconcile endpoint. Before this change, the dirty mismatch
remained blocked because Paperclip had no quarantine restore mode.

**Paperclip version or commit**

Observed on the pre-fix workspace-recovery implementation. Verified on
this PR head after rebasing onto current `master`.

**Deployment mode**

Local trusted development/worktree deployments using git worktree
execution workspaces and optional workspace runtime services.

## What Changed

- Added dirty-worktree quarantine repair that creates a rescue branch,
commits dirty tracked and untracked files there, restores the recorded
branch, writes audit comments/activity, and preserves the live foreign
branch ref.
- Added `quarantine_restore` branch reconcile API support,
recovery-action resolution, source-task wake behavior, execution-review
preservation, claimant refusal, runtime-service refusal, and coverage
for the non-transactional git ordering.
- Added board UI controls for the repair action in the recovery card
plus a compact failed-run workspace recovery surface that uses the same
reconcile handlers.
- Hardened Greptile follow-up cases by best-effort restoring the
recorded branch after a mid-sequence rescue commit failure and by
refusing quarantine restore while attached runtime services are active.

## Verification

- `pnpm exec vitest run
server/src/__tests__/execution-workspaces-service.test.ts -t
"quarantine_restore"`
- `pnpm exec vitest run server/src/__tests__/workspace-runtime.test.ts
-t "workspace dirty quarantine branch repair"`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts -t
"repairs clean unrecorded branch drift|adopts unrecorded forward branch
drift"`
- `pnpm exec vitest run
server/src/__tests__/workspace-runtime-routes-authz.test.ts`
- `pnpm --filter @paperclipai/server typecheck`
- Earlier PR verification covered the route, service, heartbeat, UI
component, and run-page recovery surfaces; Cutter posted public preview
screenshots for the repair popover and run-page panel at
https://github.com/paperclipai/paperclip/pull/9297#issuecomment-4926934211.
- GitHub PR checks are green on
`dcac76b05f4cf6e1ee16544c2831d83c7857e475`.
- Greptile is 5/5 with zero annotations and no unresolved review threads
on `dcac76b05f4cf6e1ee16544c2831d83c7857e475`.

## Risks

- Moderate risk because the change intentionally runs git commands
against local worktrees; the implementation refuses dirty repair when
another claimant or active runtime service is detected and records
rescue refs for auditability.
- Compatibility / release-note callout for self-hosted operators:
existing instances that left `enableWorkspaceBranchReconcileForward`
unset now get automatic forward branch reconciliation during heartbeat
workspace recovery. Operators who want the previous advisory-only
behavior can set `experimental.enableWorkspaceBranchReconcileForward` to
`false`; dirty quarantine repair can likewise be disabled with
`experimental.enableWorkspaceDirtyQuarantineRepair: false`.
- If the git rescue succeeds but a later database write fails, the
worktree may already be restored while the recovery action remains open;
this ordering is documented in code because git side effects cannot
participate in the database transaction.
- UI risk is limited to the workspace recovery surfaces and covered by
component tests plus the existing Cutter visual preview.

## Model Used

OpenAI Codex coding agent based on GPT-5, with repository tool use,
shell execution, and local test execution. Earlier preserved commits on
this branch also show Claude Code / Claude Opus 4.8 assistance in their
commit metadata.

## 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] Branch naming exception documented: this PR preserves the existing
worktree branch requested for publication while keeping the PR title and
body public-facing
- [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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:19:49 -05:00

218 lines
7.3 KiB
TypeScript

// @vitest-environment jsdom
import type { ComponentProps, ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { HeartbeatRun, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { RunWorkspaceRecoverySurface } from "./RunWorkspaceRecoverySurface";
import { ToastProvider } from "../context/ToastContext";
const navigateMock = vi.hoisted(() => vi.fn());
const issueGetMock = vi.hoisted(() => vi.fn());
const issueCreateMock = vi.hoisted(() => vi.fn());
const resolveRecoveryMock = vi.hoisted(() => vi.fn());
const reconcileMock = vi.hoisted(() => vi.fn());
const boardAccessMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: {
get: issueGetMock,
create: issueCreateMock,
resolveRecoveryAction: resolveRecoveryMock,
},
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: {
reconcile: reconcileMock,
},
}));
vi.mock("../api/access", () => ({
accessApi: {
getCurrentBoardAccess: boardAccessMock,
},
}));
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// flushSync-based sync `act` shim (matches the repo pattern in IssueRecoveryActionCard.test.tsx).
function act(callback: () => void): void {
flushSync(callback);
}
// Resolve pending mocked-promise micro/macrotasks, then flush the React state updates react-query
// queued, repeating so chained query → render → dependent-render settles fully.
async function flush() {
for (let i = 0; i < 6; i += 1) {
for (let j = 0; j < 4; j += 1) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
act(() => {});
}
}
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
container?.remove();
container = null;
vi.clearAllMocks();
});
function buildRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
return {
id: "run-aaaa1111",
companyId: "company-1",
agentId: "agent-1",
status: "failed",
errorCode: "workspace_validation_failed",
contextSnapshot: { issueId: "issue-1" },
resultJson: null,
contextSnapshotJson: null,
...overrides,
} as unknown as HeartbeatRun;
}
function buildRecoveryAction(): IssueRecoveryAction {
return {
id: "action-1",
companyId: "company-1",
sourceIssueId: "issue-1",
recoveryIssueId: null,
kind: "workspace_validation",
status: "active",
ownerType: "board",
ownerAgentId: null,
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "workspace_validation_failed",
fingerprint: "fp",
evidence: {
workspaceValidation: {
reason: "git_worktree_branch_incoherence",
expectedBranch: "PAP-1405-recorded",
actualBranch: "live-branch",
cleanliness: "dirty",
statusEntryCount: 2,
dirtyPathSample: ["a.ts"],
sourceIdentifier: "PAP-1405",
persistedExecutionWorkspaceId: "ws-1",
provenance: {
expectedHeadSha: "aaaa1111bbbb",
actualHeadSha: "cccc2222dddd",
ancestryVerdict: "diverged",
plainLanguageReason: "Recorded branch is not an ancestor of the live branch.",
},
},
},
nextAction: "Repair the workspace.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-07-09T00:00:00.000Z",
updatedAt: "2026-07-09T00:00:00.000Z",
};
}
function buildIssue(action: IssueRecoveryAction | null): Issue {
return {
id: "issue-1",
identifier: "PAP-1405",
companyId: "company-1",
title: "Do the thing",
description: "body",
priority: "medium",
projectId: null,
parentId: null,
assigneeAgentId: null,
executionWorkspaceId: "ws-1",
activeRecoveryAction: action,
} as unknown as Issue;
}
async function renderSurface(run: HeartbeatRun) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<QueryClientProvider client={client}>
<ToastProvider>
<RunWorkspaceRecoverySurface run={run} />
</ToastProvider>
</QueryClientProvider>,
);
});
// Let the issue + board-access queries settle and commit.
await flush();
return container!;
}
function click(element: Element | null) {
if (!element) throw new Error("Expected element to exist");
act(() => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
describe("RunWorkspaceRecoverySurface", () => {
it("renders nothing for a run that is not a workspace-validation failure", async () => {
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
boardAccessMock.mockResolvedValue(undefined);
const node = await renderSurface(buildRun({ errorCode: "process_lost" }));
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).toBeNull();
expect(issueGetMock).not.toHaveBeenCalled();
});
it("renders nothing when the source issue has no workspace_validation recovery action", async () => {
issueGetMock.mockResolvedValue(buildIssue(null));
boardAccessMock.mockResolvedValue(undefined);
const node = await renderSurface(buildRun());
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).toBeNull();
});
it("renders the compact recovery card and repair action for a dirty divergence", async () => {
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
const node = await renderSurface(buildRun());
expect(node.querySelector("[data-testid='run-workspace-recovery-surface']")).not.toBeNull();
expect(node.querySelector("[data-testid='recovery-divergence-diagnosis']")).not.toBeNull();
expect(node.querySelector("[data-testid='recovery-action-repair-trigger']")).not.toBeNull();
// Compact: metadata rows dropped.
expect(node.textContent).not.toContain("Repair the workspace.");
});
it("wires the repair confirm to reconcile in quarantine_restore mode against the pinned workspace", async () => {
issueGetMock.mockResolvedValue(buildIssue(buildRecoveryAction()));
boardAccessMock.mockResolvedValue({ source: "local_implicit", companyIds: ["company-1"] });
reconcileMock.mockResolvedValue({ id: "ws-1" });
const node = await renderSurface(buildRun());
click(node.querySelector("[data-testid='recovery-action-repair-trigger']"));
click(document.body.querySelector("[data-testid='recovery-action-repair-confirm']"));
await flush();
expect(reconcileMock).toHaveBeenCalledWith("ws-1", { mode: "quarantine_restore" });
});
});