diff --git a/server/src/__tests__/environment-retained-orphan-sandbox.test.ts b/server/src/__tests__/environment-retained-orphan-sandbox.test.ts index 480e3deb85b..86fcc370bbe 100644 --- a/server/src/__tests__/environment-retained-orphan-sandbox.test.ts +++ b/server/src/__tests__/environment-retained-orphan-sandbox.test.ts @@ -23,6 +23,7 @@ import { hasRecoveredCleanupStopReceipt } from "../services/remote-execution-ter import { taskChatRetryExecutionStopped } from "../services/chat-channels/task-retry-authority.js"; import { createOwnershipReportState, runOwnershipReconciliationPass } from "../services/recovery/ownership-invariant-report.js"; import { ORPHANED_SANDBOX_RECOVERY_KEY, settleStopOnlyCleanup } from "../services/sandbox-stop-and-retain.js"; +import { releasableRetainedOrphanCondition } from "../services/environment-retained-orphan-settlement.js"; const support = await getEmbeddedPostgresTestSupport(); const suite = support.supported ? describe : describe.skip; @@ -362,6 +363,11 @@ suite("retained orphan sandbox on the shared ownership path", () => { const newer = await warmLease(await run("succeeded"), { workspaceId: current, providerLeaseId }); return [older, newer]; }], + ["its task has no bound workspace (finished, or between bindings)", async () => { + await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, issueId)); + return [await warmLease(await run("succeeded"), { workspaceId: await workspace() }), + await warmLease(await run("succeeded"), { status: "retained", workspaceId: await workspace(), receipt: false })]; + }], ["another task still shares its workspace", async () => { const [shared, fresh] = [await workspace(), await workspace()]; await db.update(issues).set({ executionWorkspaceId: fresh }).where(eq(issues.id, issueId)); @@ -383,6 +389,24 @@ suite("retained orphan sandbox on the shared ownership path", () => { expect(await Promise.all(ids.map(row))).toEqual(before); }); + it("lists only one newest row when two rows for a sandbox share a created_at", async () => { + const current = await workspace(); + await db.update(issues).set({ executionWorkspaceId: current }).where(eq(issues.id, issueId)); + const providerLeaseId = `sandbox://fake/${randomUUID()}`, createdAt = new Date(); + const [lower, higher] = ["00000000-0000-4000-8000-000000000001", "ffffffff-ffff-4fff-bfff-ffffffffffff"]; + for (const id of [lower, higher]) { + const runId = await run("failed"); + await db.insert(environmentLeases).values({ id, companyId, environmentId, issueId, heartbeatRunId: runId, + executionWorkspaceId: current, status: "failed", cleanupStatus: "success", leasePolicy: "reuse_by_environment", + provider: "fake", providerLeaseId, acquiredAt: createdAt, releasedAt: createdAt, createdAt, updatedAt: createdAt, + metadata: { agentId, remoteExecutionTermination: { schema: "paperclip.remote-termination.v1", companyId, runId, + leaseId: id, provider: "fake", providerLeaseId, state: "stopped", confirmedAt: createdAt.toISOString() } } }); + } + const listed = await db.select({ id: environmentLeases.id }).from(environmentLeases) + .where(releasableRetainedOrphanCondition()); + expect(listed.map(row => row.id)).toEqual([higher]); + }); + it("stops, then destroys, a running warm sandbox once its task moves to another workspace", async () => { const [oldWorkspace, newWorkspace] = [await workspace(), await workspace()]; const runId = await run("succeeded"); diff --git a/server/src/services/environment-retained-orphan-settlement.ts b/server/src/services/environment-retained-orphan-settlement.ts index f876aab7a79..071cd598105 100644 --- a/server/src/services/environment-retained-orphan-settlement.ts +++ b/server/src/services/environment-retained-orphan-settlement.ts @@ -26,14 +26,20 @@ export function retainedOrphanSandboxCondition() { function newestRowForSandbox() { return sql`not exists (select 1 from environment_leases newer where newer.id <> ${environmentLeases.id} and newer.provider = ${environmentLeases.provider} and newer.provider_lease_id = ${environmentLeases.providerLeaseId} - and newer.created_at > ${environmentLeases.createdAt})`; + and (newer.created_at > ${environmentLeases.createdAt} + or (newer.created_at = ${environmentLeases.createdAt} and newer.id > ${environmentLeases.id})))`; } -/** No task is bound to this lease's workspace any more (its own task moved to - * a fresh one, and no other task shares it), so reuse, which matches the exact +/** The lease's own task is now bound to a different workspace (positive + * evidence of the move; an unbound task is not treated as moved), and no other + * task is bound to the lease's workspace, so reuse, which matches the exact * workspace and agent, can never pick the sandbox again. */ function taskMovedToAnotherWorkspace() { return and(sql`${environmentLeases.executionWorkspaceId} is not null`, + sql`exists (select 1 from issues task where task.id = ${environmentLeases.issueId} + and task.company_id = ${environmentLeases.companyId} + and task.execution_workspace_id is not null + and task.execution_workspace_id <> ${environmentLeases.executionWorkspaceId})`, sql`not exists (select 1 from issues bound where bound.company_id = ${environmentLeases.companyId} and bound.execution_workspace_id = ${environmentLeases.executionWorkspaceId})`); }