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) {