Files
PaperClipAI/ui/src/components/WorkspaceBaseRefRecovery.test.tsx
T
DottaandPaperclip b6b3cb3761 Fix unavailable workspace branches with an inline repair and retry (#15726)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Isolated workspaces must resolve a starting Git ref before an agent
starts.
> - A task can explicitly select a branch that the repository does not
have.
> - The current recovery notice calls this a missing secret and offers
no branch repair.
> - This pull request explains the branch failure and adds a task-scoped
repair action.
> - Operators can correct the branch and request a guarded retry from
the task thread.

## Linked Issues or Issue Description

Related: Refs #11892 added unresolved-ref recovery. Refs #15719 added
bounded failure diagnostics. This change uses that recovery path and
adds its operator repair UI.

**What happened?**

A task that selects `main` in a repository with only `master` stops
before the adapter starts. The task shows a missing-secret message and a
generic retry. Repeating the run cannot fix the stored branch setting.

**Expected behavior**

Explain that the starting ref could not be resolved. Offer a verified
repository default, or let the operator enter a branch. Save only the
task's starting branch and retry its recorded owner through the existing
admission gates.

**Steps to reproduce**

1. Create an isolated-workspace task in a repository with `master` and
no `main`.
2. Set the task's base ref to `main` and assign an agent.
3. Wait for the configuration failure.
4. Use the inline branch repair and confirm the agent completes in a
worktree based on `master`.

## What Changed

- Replace the missing-secret diagnosis for unresolved workspace refs.
Keep uncertain fetch/access failures described as uncertain.
- Read the remote's advertised default branch with the existing Git
credentials and a bounded timeout. Do not guess or silently change the
branch.
- Add an atomic branch repair to the existing recovery resolution
endpoint. Check company access, board runtime permission, incident
identity, current owner, task/project state, dependencies, interactions,
approvals, budget, and active execution before requesting a retry.
- Preserve the effective inherited workspace setup when saving the task
branch override, and attach one repair notice per failed run even when
the incident is reused.
- Bind the shared repair card to the exact recovery notice and failed
run in both task-thread renderers. Validate branch syntax and prevent
duplicate submissions.
- Add Storybook examples, regression tests, and a documented local
acceptance fixture with a real Git repository and process adapter.

## Verification

- Red/green regressions cover the repair endpoint and preservation of
inherited agent setup. The regression failed before each fix and passed
afterward.
- Focused integration suite: 162 tests passed; task-thread/card suite:
230 passed. After review refinements, all 13 repair-route tests, 10 card
tests, 77 recovery-action tests, and the repeated-failure heartbeat
regression passed locally.
- Real browser acceptance passed for suggested-branch and custom-entry
repairs. Invalid syntax left the task blocked. Both valid repairs ran
the authenticated worker in a real isolated worktree and reached Done;
reload preserved the result.
- Repeated failures reused the recovery incident and retained an
actionable notice on the latest run. The inherited-setup journey
verified the provisioned file, agent branch template, worktree parent
directory, and actual Git commit before completing. [Before/after
screenshots](https://github.com/paperclipai/paperclip/pull/15726#issuecomment-6088734666).
- Full local typecheck and build passed; server typecheck passed again
after review fixes. Token gates passed.
- The monolithic local Vitest run was stopped after resource-contention
timeouts and stale assertions. Targeted regressions were rerun; the full
CI matrix passed on final revision
`26c543ff490bec29ddc77a49ee532c7f84fb96dc`. One signoff E2E
worker-cancellation failure passed on a rerun without code changes. The
two opt-in Storybook workflow jobs were skipped; the Storybook journey
was checked manually.
- Repeat the live journey with `tests/e2e/workspace-base-ref/README.md`.
Storybook examples use simulated callbacks and are not the live
acceptance proof.

## Risks

- Default-branch discovery can add up to 10 seconds to an unresolved-ref
failure. Lookup failure leaves manual entry available.
- A valid branch name can still be absent or inaccessible. The retried
workspace preparation remains authoritative and can block again with new
evidence.
- Retry remains subject to normal admission gates. The repair changes
only the task override; it does not alter project or agent defaults.
- No database migration or new dependency.

## Model Used

OpenAI GPT-6 through Codex, with tool execution, repository analysis,
and browser testing. The exact serving variant and context-window size
are not exposed in this session.

## 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
- [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>
2026-10-09 15:56:14 -05:00

67 lines
4.4 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Issue, IssueCommentMetadata } from "@paperclipai/shared";
import { WorkspaceBaseRefRecoveryProvider, useWorkspaceBaseRefRecovery } from "./WorkspaceBaseRefRecovery";
import { WorkspaceBaseRefRecoveryNotice } from "./WorkspaceBaseRefRecoveryNotice";
const mocks = vi.hoisted(() => ({ get: vi.fn(), resolve: vi.fn() }));
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: { get: mocks.get } }));
vi.mock("../api/issues", () => ({ issuesApi: { resolveRecoveryAction: mocks.resolve } }));
const issue = { id: "issue", companyId: "company", status: "blocked", assigneeAgentId: "agent",
executionWorkspaceSettings: { workspaceStrategy: { baseRef: "main" } },
activeRecoveryAction: { id: "action", status: "active", cause: "configuration_incomplete", returnOwnerAgentId: "agent", evidence: { latestRunId: "run" } },
} as unknown as Issue;
const metadata: IssueCommentMetadata = { version: 1, sourceRunId: "run", sections: [{ rows: [{ type: "key_value", label: "Recovery action", value: "action" }] }] };
function Consumer({ data = metadata }: { data?: IssueCommentMetadata }) {
const value = useWorkspaceBaseRefRecovery(data);
return value ? <WorkspaceBaseRefRecoveryNotice {...value.props} /> : <p>No repair</p>;
}
let container: HTMLDivElement, root: Root, client: QueryClient;
beforeEach(() => {
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mocks.resolve.mockReset();
mocks.get.mockResolvedValue({ companyId: "company", agentId: "agent", contextSnapshot: { issueId: "issue" },
resultJson: { configurationIncomplete: { reason: "workspace_base_ref_unresolved", requestedRef: "main", defaultBranch: "master" } } });
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); });
async function render(data = metadata, unavailableReason: string | null = null) {
await act(async () => {
root.render(<QueryClientProvider client={client}><WorkspaceBaseRefRecoveryProvider issue={issue}
agentMap={new Map([["agent", { name: "Worker", status: "idle" }]])} unavailableReason={unavailableReason} onRepaired={() => {}}>
<Consumer data={data} /></WorkspaceBaseRefRecoveryProvider></QueryClientProvider>);
await new Promise(resolve => setTimeout(resolve, 0));
});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
}
it("binds the repair to the exact notice and sends an atomic recovery request", async () => {
mocks.resolve.mockResolvedValue({ issue: { ...issue, status: "todo", executionWorkspaceSettings: { workspaceStrategy: { baseRef: "master" } } } });
await render();
const button = [...container.querySelectorAll("button")].find(b => b.textContent === "Use master & retry")!;
await act(async () => button.click());
expect(mocks.resolve).toHaveBeenCalledExactlyOnceWith("issue", { actionId: "action", outcome: "restored", sourceIssueStatus: "todo", workspaceBaseRef: { requestedRef: "main", branch: "master" } });
expect(container.textContent).toContain("Branch updated · retry requested");
});
it("does not turn an unrelated notice into a repair", async () => {
await render({ ...metadata, sections: [{ rows: [{ type: "key_value", label: "Recovery action", value: "other-action" }] }] });
expect(container.textContent).toBe("No repair");
});
it("does not report success from an incomplete receipt", async () => {
mocks.resolve.mockResolvedValue({ issue });
await render();
await act(async () => [...container.querySelectorAll("button")].find(b => b.textContent === "Use master & retry")!.click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain("The task changed while saving");
expect(container.textContent).not.toContain("Branch updated · retry requested");
});
it("renders only the latest run notice when a repeated failure reuses the incident", async () => {
await render({ ...metadata, sourceRunId: "first-failed-run" });
expect(container.textContent).toBe("No repair");
await render(metadata);
expect(mocks.get).toHaveBeenCalledWith("run");
expect(container.textContent).toContain("Use master & retry");
});