diff --git a/server/src/services/automatic-sandbox-continuation.test.ts b/server/src/services/automatic-sandbox-continuation.test.ts index 26b3911b9e..a27bde9183 100644 --- a/server/src/services/automatic-sandbox-continuation.test.ts +++ b/server/src/services/automatic-sandbox-continuation.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, authUsers, companies, createDb, environmentLeases, environments, heartbeatRuns, issueComments, issueRecoveryActions, issues, issueThreadInteractions } from "@paperclipai/db"; @@ -90,6 +90,42 @@ const support = await getEmbeddedPostgresTestSupport(); unregisterServerAdapter(adapterType); } }); + it("keeps adapter type and config together when settings change after claim", async () => { + const f = await seed(); + const beforeType = "claim_adapter_before", afterType = "claim_adapter_after"; + const executed: { type: string; marker: unknown }[] = []; + for (const type of [beforeType, afterType]) registerServerAdapter({ type, + execute: async ({ config }) => { executed.push({ type, marker: config.marker }); return { exitCode: 0, signal: null, timedOut: false }; }, + testEnvironment: async () => ({ adapterType: type, status: "pass", checks: [], testedAt: new Date().toISOString() }), + }); + const heartbeat = heartbeatService(db); + try { + await db.delete(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + await db.update(agents).set({ adapterType: beforeType, adapterConfig: { marker: "before" } }).where(eq(agents.id, f.agentId)); + await db.update(heartbeatRuns).set({ status: "queued", errorCode: null, error: null, finishedAt: null }).where(eq(heartbeatRuns.id, f.run.id)); + // Change settings after the queued claim, before executeRun reads them. + await db.execute(sql.raw(`create function recovery_test_adapter_change() returns trigger language plpgsql as $$ + begin + if OLD.status = 'queued' and NEW.status = 'running' and NEW.id = '${f.run.id}'::uuid then + update agents set adapter_type = '${afterType}', adapter_config = '{"marker":"after"}'::jsonb where id = '${f.agentId}'::uuid; + end if; + return NEW; + end $$`)); + await db.execute(sql.raw('create trigger recovery_test_adapter_change after update on heartbeat_runs for each row execute function recovery_test_adapter_change()')); + await heartbeat.resumeQueuedRuns(); + await vi.waitFor(async () => { + const [saved] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)); + expect(saved.status).toBe("succeeded"); + expect(saved.runnerProfileJson?.adapterDispatch).toEqual({ adapterType: afterType }); + }, { timeout: 10000 }); + expect(executed).toEqual([{ type: afterType, marker: "after" }]); + } finally { + await heartbeat.drainActiveRunExecutions(); + await db.execute(sql.raw('drop trigger if exists recovery_test_adapter_change on heartbeat_runs')); + await db.execute(sql.raw('drop function if exists recovery_test_adapter_change()')); + unregisterServerAdapter(beforeType); unregisterServerAdapter(afterType); + } + }); it("automatically resumes a historical startup failure after exact provider termination", async () => { const f = await seed(); await heartbeatService(db).resumeInterruptedSandboxRuns(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 1d81a460ea..26ceb23f38 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -19360,7 +19360,7 @@ export function heartbeatService( let providerTraceFinalized = false; try { - let agent = await getAgent(run.agentId); + const agent = await getAgent(run.agentId); if (!agent) { await setRunStatus(runId, "failed", { error: "Agent not found", @@ -19377,8 +19377,21 @@ export function heartbeatService( } const selectedAdapter = (run.runnerProfileJson?.adapterDispatch as Record | undefined)?.adapterType; - if (typeof selectedAdapter === "string" && selectedAdapter !== agent.adapterType) { - agent = { ...agent, adapterType: selectedAdapter }; + if (run.runtimeMode === "legacy" && typeof selectedAdapter === "string" && selectedAdapter !== agent.adapterType) { + // Settings can change between claim and preparation. Select type and + // config from the same agent snapshot, then persist the actual adapter + // before any provisioning. Never combine an old type with new config. + await controllerLease.assertOwned(); + const [selectedRun] = await db.update(heartbeatRuns).set({ + runnerProfileJson: sql`${heartbeatRuns.runnerProfileJson} || ${JSON.stringify({ adapterDispatch: { adapterType: agent.adapterType } })}::jsonb`, + }).where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.status, "running"), run.controllerBootId + ? eq(heartbeatRuns.controllerBootId, legacyControllerBootId) : isNull(heartbeatRuns.controllerBootId))).returning(); + if (!selectedRun) { + executionControl.controller.abort(new LegacyControllerLeaseLostError()); + executionControl.controller.signal.throwIfAborted(); + } + run = selectedRun; } const runtime = await ensureRuntimeState(agent); const context = parseObject(run.contextSnapshot);