fix(server): keep the agent invokable when a run fails on a workspace sync conflict (#10660)

## 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
This commit is contained in:
Devin Foley 2026-08-01 17:53:20 -07:00 committed by GitHub
parent 592cade5a6
commit e4b0152ca3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 48 additions and 1 deletions

View File

@ -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);
});
});

View File

@ -3907,6 +3907,26 @@ export function describeSessionResetReason(
return null;
}
/**
* Failure signatures from sandboxhost 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) {