fix: keep adapter selection and configuration consistent before provisioning
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
79d05f4f1f
commit
345b84063a
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue