mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +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>
78 lines
4.9 KiB
TypeScript
78 lines
4.9 KiB
TypeScript
import { createContext, useContext, type ReactNode } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import type { Agent, Issue, IssueCommentMetadata } from "@paperclipai/shared";
|
||
import { heartbeatsApi } from "../api/heartbeats";
|
||
import { issuesApi } from "../api/issues";
|
||
import type { WorkspaceBaseRefRecoveryNoticeProps } from "./WorkspaceBaseRefRecoveryNotice";
|
||
|
||
function record(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||
}
|
||
|
||
type RecoveryValue = { actionId: string; runId: string; props: WorkspaceBaseRefRecoveryNoticeProps };
|
||
const Context = createContext<RecoveryValue | null>(null);
|
||
|
||
/** Bind the repair to the server-owned action and its latest failed run. */
|
||
export function useWorkspaceBaseRefRecovery(metadata: IssueCommentMetadata | null | undefined) {
|
||
const value = useContext(Context);
|
||
return value && metadata?.sourceRunId === value.runId
|
||
&& metadata.sections.some(section => section.rows.some(row => row.type === "key_value" && row.value === value.actionId))
|
||
? value : null;
|
||
}
|
||
|
||
export function WorkspaceBaseRefRecoveryProvider({ issue, agentMap, unavailableReason, onRepaired, children }: {
|
||
issue: Issue;
|
||
agentMap: ReadonlyMap<string, Pick<Agent, "name" | "status">>;
|
||
unavailableReason: string | null;
|
||
onRepaired: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
const action = issue.activeRecoveryAction;
|
||
const runId = action?.cause === "configuration_incomplete" && typeof action.evidence.latestRunId === "string"
|
||
? action.evidence.latestRunId : null;
|
||
const { data: run } = useQuery({
|
||
queryKey: ["workspace-base-ref-recovery", issue.companyId, issue.id, runId],
|
||
queryFn: () => heartbeatsApi.get(runId!), enabled: Boolean(runId), staleTime: Infinity, retry: false,
|
||
});
|
||
const failure = record(record(run?.resultJson).configurationIncomplete);
|
||
let value: RecoveryValue | null = null;
|
||
if (action && runId && run?.companyId === issue.companyId && record(run.contextSnapshot).issueId === issue.id
|
||
&& failure.reason === "workspace_base_ref_unresolved" && typeof failure.requestedRef === "string") {
|
||
const agent = issue.assigneeAgentId ? agentMap.get(issue.assigneeAgentId) : undefined;
|
||
const requestedRef = failure.requestedRef;
|
||
const workspace = issue.project?.workspaces?.find(w => w.id === issue.projectWorkspaceId)
|
||
?? issue.project?.primaryWorkspace;
|
||
let repository = "The task’s repository";
|
||
if (workspace?.repoUrl) {
|
||
try { const url = new URL(workspace.repoUrl); if (url.protocol === "https:" || url.protocol === "http:") repository = `${url.hostname}${url.pathname.replace(/\.git$/, "")}`; } catch { /* Keep local paths and credentials out of the card. */ }
|
||
}
|
||
const settings = record(issue.executionWorkspaceSettings);
|
||
const currentRef = record(settings.workspaceStrategy).baseRef;
|
||
const stale = action.status !== "active" || issue.status !== "blocked" || issue.assigneeAgentId !== action.returnOwnerAgentId
|
||
|| run.agentId !== issue.assigneeAgentId || (typeof currentRef === "string" && Boolean(currentRef.trim()) && currentRef !== requestedRef);
|
||
const reason = stale ? "This repair no longer matches the task. Refresh to review its current state."
|
||
: unavailableReason
|
||
?? (issue.executionRunId || issue.checkoutRunId ? "Wait for the current run to finish." : null)
|
||
?? (agent?.status === "paused" || agent?.status === "terminated" ? "The assigned agent is unavailable." : null)
|
||
?? (issue.executionState?.status === "pending" ? "Complete the pending review or approval before retrying." : null)
|
||
?? (issue.blockedBy?.some(b => b.status !== "done" && b.status !== "cancelled") ? "Resolve the task’s blockers before retrying." : null);
|
||
value = { actionId: action.id, runId, props: {
|
||
requestedRef, repository, taskOverride: currentRef === requestedRef, defaultBranch: typeof failure.defaultBranch === "string" ? failure.defaultBranch : null,
|
||
agentName: agent?.name ?? "The assigned agent", unavailableReason: reason,
|
||
failureKind: typeof failure.fetchError === "string" && failure.fetchError.includes(`couldn't find remote ref refs/heads/${requestedRef}`) ? "missing_branch" : "unresolved_ref",
|
||
onRepair: async branch => {
|
||
try {
|
||
const receipt = await issuesApi.resolveRecoveryAction(issue.id, {
|
||
actionId: action.id, outcome: "restored", sourceIssueStatus: "todo", workspaceBaseRef: { requestedRef, branch },
|
||
});
|
||
if (receipt.issue.status !== "todo" || receipt.issue.assigneeAgentId !== issue.assigneeAgentId
|
||
|| record(record(receipt.issue.executionWorkspaceSettings).workspaceStrategy).baseRef !== branch) {
|
||
throw new Error("The task changed while saving. Refresh to check its current state.");
|
||
}
|
||
} finally { onRepaired(); }
|
||
},
|
||
} };
|
||
}
|
||
return <Context.Provider value={value}>{children}</Context.Provider>;
|
||
}
|