diff --git a/server/src/services/execution-control-reconciliation.test.ts b/server/src/services/execution-control-reconciliation.test.ts index 1cd52611df5..d53a2870965 100644 --- a/server/src/services/execution-control-reconciliation.test.ts +++ b/server/src/services/execution-control-reconciliation.test.ts @@ -149,6 +149,49 @@ describeEmbeddedPostgres("reconcileAbandonedExecutionControl reports a genuine f }); }); + it("keeps the run's prior failure code as the reported root cause", async () => { + const { runId } = await seedAbandonedRunFixture(); + await db.update(heartbeatRuns).set({ + errorCode: "native_execution_ownership_unverified", + resultJson: { workspaceRestoreFailure: "restore_failed" }, + }).where(eq(heartbeatRuns.id, runId)); + const captureCallsBefore = mockCaptureRunFailure.mock.calls.length; + + await reconcileAbandonedExecutionControl(db); + + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(run).toMatchObject({ + status: "failed", + errorCode: "execution_finalization_deadline_exceeded", + resultJson: { workspaceRestoreFailure: "restore_failed", originalFailureCode: "native_execution_ownership_unverified" }, + }); + await vi.waitFor(() => { + expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)).toHaveLength(1); + }, { timeout: 5_000 }); + expect(mockCaptureRunFailure.mock.calls.slice(captureCallsBefore)[0]?.[0]).toMatchObject({ + runId, + errorCode: "execution_finalization_deadline_exceeded", + diagnostics: { execution: { originalFailureCode: "native_execution_ownership_unverified" } }, + }); + }); + + it("does not replace an earlier recorded root cause or record the deadline as its own cause", async () => { + const earlier = await seedAbandonedRunFixture(); + await db.update(heartbeatRuns).set({ + errorCode: "native_execution_ownership_unverified", + resultJson: { originalFailureCode: "native_provider_session_failed" }, + }).where(eq(heartbeatRuns.id, earlier.runId)); + const repeated = await seedAbandonedRunFixture(); + await db.update(heartbeatRuns).set({ errorCode: "execution_finalization_deadline_exceeded" }) + .where(eq(heartbeatRuns.id, repeated.runId)); + + await reconcileAbandonedExecutionControl(db); + + const runs = await db.select().from(heartbeatRuns).where(inArray(heartbeatRuns.id, [earlier.runId, repeated.runId])); + expect(runs.find(run => run.id === earlier.runId)?.resultJson).toEqual({ originalFailureCode: "native_provider_session_failed" }); + expect(runs.find(run => run.id === repeated.runId)?.resultJson ?? {}).not.toHaveProperty("originalFailureCode"); + }); + it("leaves a run owned by a live legacy controller to that controller", async () => { const { runId } = await seedAbandonedRunFixture(); const leaseExpiresAt = new Date(Date.now() + 45_000); diff --git a/server/src/services/execution-control-reconciliation.ts b/server/src/services/execution-control-reconciliation.ts index 0133f215d27..3e4c5a080cd 100644 --- a/server/src/services/execution-control-reconciliation.ts +++ b/server/src/services/execution-control-reconciliation.ts @@ -219,6 +219,11 @@ export async function reconcileAbandonedExecutionControl( const cause = "execution_finalization_deadline_exceeded"; const nextAction = "Inspect the failed run and verify its provider has stopped. Reconcile any uncertain external action before explicitly continuing this task."; + // The deadline replaces the run's error code. Keep the earlier + // diagnosis as its root cause, as the native finalizer does. + const priorFailureCode = coordinator?.failureCode ?? run.errorCode; + const recordsOriginalFailure = Boolean(priorFailureCode) && priorFailureCode !== cause && + typeof run.resultJson?.originalFailureCode !== "string"; if (coordinator) await tx .update(nativeRunFinalizations) @@ -256,6 +261,9 @@ export async function reconcileAbandonedExecutionControl( errorCode: cause, error: nextAction, nextAction, + ...(recordsOriginalFailure + ? { resultJson: { ...run.resultJson, originalFailureCode: priorFailureCode } } + : {}), updatedAt: now, }) .where(eq(heartbeatRuns.id, run.id))