diff --git a/server/src/__tests__/environment-run-lease-ownership.test.ts b/server/src/__tests__/environment-run-lease-ownership.test.ts index a8a666a0ebc..669293cf660 100644 --- a/server/src/__tests__/environment-run-lease-ownership.test.ts +++ b/server/src/__tests__/environment-run-lease-ownership.test.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, @@ -257,6 +257,17 @@ suite("environment_run_lease_active on the shared ownership path", () => { }); describe("leases saved before holders were stamped", () => { + it("dates the first cleanup attempt as a top-level key even for a scalar metadata root", async () => { + const runId = await run("failed"); + const id = randomUUID(); + await db.insert(environmentLeases).values({ id, companyId, heartbeatRunId: runId, status: "active", + metadata: sql`'["provider-wrote-an-array"]'::jsonb` }); + await runOwnershipReconciliationPass(db, { state: createOwnershipReportState() }); + const settled = await row(id); + expect(settled.status).toBe("pending_cleanup"); + expect(typeof settled.metadata?.pendingCleanupRecoveredAtMs).toBe("number"); + }); + it("keeps the run fact alone for one release on a single-host server", async () => { const runId = await run("failed"); const leaseId = await lease(runId, null); diff --git a/server/src/services/environment-run-lease-settlement.ts b/server/src/services/environment-run-lease-settlement.ts index fb1c79a471e..f6020a1ed6f 100644 --- a/server/src/services/environment-run-lease-settlement.ts +++ b/server/src/services/environment-run-lease-settlement.ts @@ -2,6 +2,7 @@ import { and, eq, inArray, ne, notInArray, or, sql } from "drizzle-orm"; import { environmentLeases, environments, heartbeatRuns, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db"; import { currentControllerBootId } from "./controller-process-identity.js"; import { writeEnvironmentCleanup } from "./environment-cleanup-notifications.js"; +import { pendingCleanupMetadataObjectSql } from "./environment-cleanup-state.js"; import { readEnvironmentLeaseController } from "./environment-lease-controller.js"; import { allowLegacyShutdownWorkspaceCleanup } from "./legacy-shutdown-workspace-settlement.js"; import { nativeWorkspaceExportPendingForLease } from "./native-runtime/native-workspace-finalization-state.js"; @@ -77,7 +78,8 @@ export async function settleEnvironmentRunLease(db: Db, record: InProgressRecord const flipped = await writeEnvironmentCleanup(tx, write => write.update(environmentLeases) .set({ status: "pending_cleanup", failureReason: ORPHANED_RUN_LEASE_FAILURE_REASON, // When the first cleanup attempt became due (the row keeps updatedAt). - metadata: sql`coalesce(${environmentLeases.metadata}, '{}'::jsonb) || ${JSON.stringify({ + // A scalar or array root reads as an empty object, so the key is top level. + metadata: sql`${pendingCleanupMetadataObjectSql()} || ${JSON.stringify({ pendingCleanupRecoveredAtMs: context.now.getTime() })}::jsonb` }) .where(and(eq(environmentLeases.id, lease.id), eq(environmentLeases.status, "active"), eq(environmentLeases.heartbeatRunId, runId),