mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## 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>
74 lines
4.4 KiB
TypeScript
74 lines
4.4 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act } from "react";
|
||
import { createRoot, type Root } from "react-dom/client";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { WorkspaceBaseRefRecoveryNotice, type WorkspaceBaseRefRecoveryNoticeProps } from "./WorkspaceBaseRefRecoveryNotice";
|
||
|
||
const evidence = { requestedRef: "main", repository: "paperclipai/paperclip", defaultBranch: "master", agentName: "Felix", failureKind: "missing_branch" as const };
|
||
describe("workspace branch repair", () => {
|
||
let container: HTMLDivElement;
|
||
let root: Root;
|
||
beforeEach(() => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); });
|
||
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
|
||
const button = (name: string) => Array.from(container.querySelectorAll("button")).find(b => b.textContent === name)!;
|
||
const render = async (props: Partial<WorkspaceBaseRefRecoveryNoticeProps> = {}) => act(async () => { root.render(<WorkspaceBaseRefRecoveryNotice {...evidence} onRepair={async () => {}} {...props} />); });
|
||
|
||
it("shows the missing branch and verified default without a secrets diagnosis", async () => {
|
||
await render();
|
||
expect(container.textContent).toContain("Branch main was not found");
|
||
expect(container.textContent).toContain("Felix hasn’t started");
|
||
expect(container.textContent).not.toContain("secret");
|
||
expect(button("Use master & retry").disabled).toBe(false);
|
||
expect(container.textContent).toContain("Changes only this task");
|
||
});
|
||
it("waits for a repair receipt and prevents duplicate submissions", async () => {
|
||
let finish!: () => void;
|
||
const repair = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
|
||
await render({ onRepair: repair });
|
||
await act(async () => { button("Use master & retry").click(); button("Use master & retry")?.click(); });
|
||
expect(repair).toHaveBeenCalledExactlyOnceWith("master");
|
||
expect(button("Saving & requesting retry…").disabled).toBe(true);
|
||
expect(container.textContent).not.toContain("Branch updated");
|
||
await act(async () => finish());
|
||
expect(container.textContent).toContain("Branch updated · retry requested");
|
||
expect(button("Use master & retry")).toBeUndefined();
|
||
});
|
||
it("keeps a custom branch after failure and permits correction", async () => {
|
||
const repair = vi.fn().mockRejectedValue(new Error("Branch was not found."));
|
||
await render({ onRepair: repair });
|
||
await act(async () => button("Choose another branch").click());
|
||
const input = container.querySelector("input")!;
|
||
await act(async () => {
|
||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "release/next");
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
await act(async () => container.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
|
||
expect(repair).toHaveBeenCalledExactlyOnceWith("release/next");
|
||
expect(input.value).toBe("release/next");
|
||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Branch was not found");
|
||
expect(button("Save branch & retry").disabled).toBe(false);
|
||
expect(container.textContent).not.toContain("Branch updated");
|
||
});
|
||
it("rejects invalid branch syntax before sending a repair", async () => {
|
||
const repair = vi.fn();
|
||
await render({ onRepair: repair, defaultBranch: "bad..branch" });
|
||
await act(async () => button("Use bad..branch & retry").click());
|
||
expect(repair).not.toHaveBeenCalled();
|
||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Enter a valid branch name");
|
||
});
|
||
it("does not guess a default branch or call an unresolved ref missing", async () => {
|
||
await render({ defaultBranch: null, failureKind: "unresolved_ref" });
|
||
expect(button("Use master & retry")).toBeUndefined();
|
||
expect(container.textContent).not.toContain("has no branch");
|
||
expect(container.querySelector("input")?.value).toBe("");
|
||
expect(button("Save branch & retry").disabled).toBe(true);
|
||
});
|
||
it("does not submit a disabled repair", async () => {
|
||
const repair = vi.fn();
|
||
await render({ onRepair: repair, unavailableReason: "The task is paused." });
|
||
expect(button("Use master & retry").disabled).toBe(true);
|
||
await act(async () => button("Use master & retry").click());
|
||
expect(repair).not.toHaveBeenCalled();
|
||
});
|
||
});
|