From e4b0152ca37a5ec52e90305968c43899a391bea5 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 1 Aug 2026 17:53:20 -0700 Subject: [PATCH] fix(server): keep the agent invokable when a run fails on a workspace sync conflict (#10660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents on the same project can share one `shared_workspace` clone, and each sandbox run reconciles its git history back into it > - When histories written by different runs genuinely diverge, reconciliation can hit a real merge conflict — a property of the *workspace*, not of whichever agent happened to run last > - That agent nonetheless finalized into a sticky `error` state, removing a healthy agent from rotation while the workspace stayed broken — and on a shared workspace this serially knocks out every agent that touches it > - This pull request classifies workspace-reconciliation failure signatures as workspace-scoped, so the run still fails with the full message but the agent stays invokable > - The benefit is that one bad workspace state no longer disables agents one by one ## Linked Issues or Issue Description Refs #10645 — this addresses the sticky-agent-error clause of that issue. Workspace run serialization / per-agent worktrees remain tracked there (design sketch on the issue). ## What Changed - New exported `isWorkspaceSyncConflictFailure(message)` matching the reconciliation failure signatures: `merge-tree` conflict ("Failed to merge concurrent remote git histories"), integrate-retry exhaustion ("Failed to integrate concurrent remote git history"), and bundle prerequisite failures ("did not send all necessary objects", "lacks these prerequisite commits"). - Both run-failure finalization paths (adapter returned a failed result; adapter threw) pass `keepIdleOnFailure` for these signatures — the same mechanism already used for provider-quota failures — so the agent finalizes to `idle` instead of `error`. The run itself still fails and carries the full message; nothing about run reporting changes. ## Verification - `pnpm vitest run server/src/__tests__/heartbeat-workspace-session.test.ts` — new signature matrix (4 positive signatures, negatives for unrelated adapter failures and null/empty); 121 tests total. - `cd server && pnpm run typecheck`. ## Risks - Low. The only change is which failure families put the agent into `error`; behavior for every other failure is untouched. A workspace stuck in conflict still fails every run against it (visible on the runs surface) — it just no longer takes agents down with it. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — extended thinking, agentic tool use. No other models involved. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../heartbeat-workspace-session.test.ts | 25 +++++++++++++++++++ server/src/services/heartbeat.ts | 24 +++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index a7ef9da9d5..b33840fee8 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -31,6 +31,7 @@ import { resolveNextSessionState, resolveTaskSessionConfigFreshness, issueTextImpliesPrDeliverable, + isWorkspaceSyncConflictFailure, requiresPushCapabilityPreflight, resolveWorkspaceAfterLowTrustPreflight, resolveRuntimeSessionParamsForWorkspace, @@ -2642,3 +2643,27 @@ describe("parseSessionCompactionPolicy", () => { }); }); }); + +describe("isWorkspaceSyncConflictFailure", () => { + it("matches the git workspace reconciliation failure signatures", () => { + expect(isWorkspaceSyncConflictFailure( + "Failed to merge concurrent remote git histories for a5d46a8005b3 and c1042c11774a: Command failed: git merge-tree --write-tree", + )).toBe(true); + expect(isWorkspaceSyncConflictFailure( + "Failed to integrate concurrent remote git history for a5d46a8005b3 after multiple retries.", + )).toBe(true); + expect(isWorkspaceSyncConflictFailure( + "error: /tmp/restore/git-delta.bundle did not send all necessary objects", + )).toBe(true); + expect(isWorkspaceSyncConflictFailure( + "error: Repository lacks these prerequisite commits: 4c631700", + )).toBe(true); + }); + + it("ignores unrelated adapter failures", () => { + expect(isWorkspaceSyncConflictFailure("Codex exited with code 2")).toBe(false); + expect(isWorkspaceSyncConflictFailure("no Codex credentials provisioned for managed home")).toBe(false); + expect(isWorkspaceSyncConflictFailure(null)).toBe(false); + expect(isWorkspaceSyncConflictFailure("")).toBe(false); + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index b535b0d1ce..21a27fde5b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3907,6 +3907,26 @@ export function describeSessionResetReason( return null; } +/** + * Failure signatures from sandbox→host git workspace reconciliation. These + * describe the state of the SHARED workspace (divergent histories written by + * different runs), not a defect in the agent that happened to run last — + * putting the agent into a sticky `error` state over them removes a healthy + * agent from rotation while leaving the actual problem (the workspace) + * untouched. The run still fails and carries the full message. + */ +const WORKSPACE_SYNC_CONFLICT_SIGNATURES = [ + "Failed to merge concurrent remote git histories", + "Failed to integrate concurrent remote git history", + "did not send all necessary objects", + "lacks these prerequisite commits", +]; + +export function isWorkspaceSyncConflictFailure(message: string | null | undefined): boolean { + if (!message) return false; + return WORKSPACE_SYNC_CONFLICT_SIGNATURES.some((signature) => message.includes(signature)); +} + export function shouldDeferFollowupWakeForSameIssue(input: { activeRunStatus: string | null | undefined; isSameExecutionAgent: boolean; @@ -15139,7 +15159,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) { keepIdleOnFailure: outcome === "failed" && - (finalizedRun ? readHeartbeatRunErrorFamily(finalizedRun) === "provider_quota" : runErrorCode === "provider_quota"), + ((finalizedRun ? readHeartbeatRunErrorFamily(finalizedRun) === "provider_quota" : runErrorCode === "provider_quota") || + isWorkspaceSyncConflictFailure(adapterResult.errorMessage)), wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run), }, ); @@ -15266,6 +15287,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) await finalizeAgentStatus(agent.id, "failed", message, { wasFirstHeartbeat: timerClaimWasFirstHeartbeat(run), + keepIdleOnFailure: isWorkspaceSyncConflictFailure(message), }); } } catch (outerErr) {