fix: keep adapter selection and configuration consistent before provisioning

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Harold Kim 2026-09-11 22:51:18 +00:00
parent 79d05f4f1f
commit 345b84063a
No known key found for this signature in database
GPG Key ID: 4861541D36B2037E
2 changed files with 53 additions and 4 deletions

View File

@ -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();

View File

@ -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<string, unknown> | 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);