From ea52c83823980c43a1b819704182a8434ca3e8b6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 15:45:44 -0500 Subject: [PATCH] fix: retain supported ACPX readiness checks after integration Update stop lifecycle fixtures to require verified teardown before acknowledgement. Co-Authored-By: Paperclip --- server/src/__tests__/adapter-registry.test.ts | 15 ++++++++----- .../heartbeat-process-metadata.test.ts | 22 ++++++++++++------- server/src/__tests__/run-trust-preset.test.ts | 3 ++- server/src/adapters/registry.ts | 12 +++++++--- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index d7cf38976a..f2e12d9849 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -279,6 +279,8 @@ describe("server adapter registry", () => { it.each([ ["claude", "claude-sonnet-5"], + ["codex", "gpt-5.6-sol"], + ["pi", "openrouter/deepseek/deepseek-v4-flash-0731"], ] as const)("does not claim runtime readiness from the remote ACPX %s platform alone", async (acpxAgent, model) => { const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", @@ -315,20 +317,23 @@ describe("server adapter registry", () => { }); }); - it("accepts the qualified ACPX Pi profile", async () => { + it.each([ + ["codex", "gpt-5.6-sol"], + ["pi", "openrouter/deepseek/deepseek-v4-flash-0731"], + ])("accepts qualified ACPX %s without claiming installation readiness", async (acpxAgent, model) => { const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", config: { provider: "acpx", - acpxAgent: "pi", - model: "openrouter/deepseek/deepseek-v4-flash-0731", + acpxAgent, + model, }, }); expect(result).toMatchObject({ - status: "pass", - checks: [{ code: "acpx_profile_qualified", level: "info" }], + status: "warn", + checks: [{ code: "acpx_runtime_unverified", level: "warn" }], }); }); it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => { diff --git a/server/src/__tests__/heartbeat-process-metadata.test.ts b/server/src/__tests__/heartbeat-process-metadata.test.ts index 0b16cfd206..cc654c9a6e 100644 --- a/server/src/__tests__/heartbeat-process-metadata.test.ts +++ b/server/src/__tests__/heartbeat-process-metadata.test.ts @@ -1,12 +1,12 @@ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { agents, companies, createDb, heartbeatRuns, startEmbeddedPostgresTestDatabase, type Db } from "@paperclipai/db"; import * as processes from "../services/hot-restart.js"; import * as adapters from "../adapters/index.js"; import * as orchestration from "../services/environment-run-orchestrator.js"; import * as compatibility from "../services/legacy-sandbox-workspace.js"; -import * as cancellation from "@paperclipai/adapter-utils/adapter-run-cancellation"; +import * as gitCredentials from "../services/git-credentials.js"; import { bindAdapterRunStop, hasAdapterRunCancellation } from "@paperclipai/adapter-utils/adapter-run-cancellation"; import * as executionTargets from "@paperclipai/adapter-utils/execution-target"; import { heartbeatService, persistHeartbeatRunProcessMetadata } from "../services/heartbeat.js"; @@ -22,6 +22,12 @@ describe("heartbeat process identity persistence", () => { await db.insert(agents).values({ id: agentId, companyId, name: "Runner", role: "engineer", status: "idle", adapterType: "codex_local" }); }, 60_000); afterEach(() => vi.restoreAllMocks()); + beforeEach(() => { + // These tests exercise process/cancellation ownership, not Git transport. + // Their synthetic targets deliberately have no remote command runner. + vi.spyOn(executionTargets, "prepareGitHubExecutionEnvironment").mockImplementation(async (input) => input.env); + vi.spyOn(gitCredentials, "resolveManagedGitHubIdentitySelection").mockResolvedValue({ configured: true }); + }); afterAll(async () => { await database?.cleanup(); }); async function running() { const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running", invocationSource: "on_demand", startedAt: new Date(), @@ -103,7 +109,7 @@ describe("heartbeat process identity persistence", () => { } finally { release(); await heartbeat.drainActiveRunExecutions(); } }, 30_000); - it.each([false, true])("cancels the remote adapter and waits for teardown (scope lookup raced: %s)", async (scopeLookupRaced) => { + it("cancels the remote adapter and waits for teardown before acknowledging Stop", async () => { const originalOrchestrator = orchestration.environmentRunOrchestrator; vi.spyOn(orchestration, "environmentRunOrchestrator").mockImplementation((...args) => { const actual = originalOrchestrator(...args); @@ -125,14 +131,17 @@ describe("heartbeat process identity persistence", () => { execute: async (input) => { expect(hasAdapterRunCancellation(input.runId)).toBe(true); const cleanup = await bindAdapterRunStop(input.runId, async () => { - expect((await heartbeat.getRun(input.runId))?.status).toBe("cancelled"); + const run = await heartbeat.getRun(input.runId); + expect(run?.status).toBe("running"); + expect(run?.resultJson?.executionCancellation).toMatchObject({ state: "requested" }); stopped(); }); ready(); await interrupted; await teardown; await cleanup(); - return { exitCode: 143, signal: "SIGTERM", timedOut: false }; + return { exitCode: 143, signal: "SIGTERM", timedOut: false, + resultJson: { executionCancellation: { state: "acknowledged" } } }; }, } as ReturnType); let pending: Promise | undefined; @@ -140,9 +149,6 @@ describe("heartbeat process identity persistence", () => { const queued = await heartbeat.invoke(agentId, "on_demand", {}, "manual"); expect(queued).not.toBeNull(); await started; - // Model the initial lookup occurring before registration. The final - // lookup after persisting cancellation must still notify the scope. - if (scopeLookupRaced) vi.spyOn(cancellation, "hasAdapterRunCancellation").mockReturnValueOnce(false); let acknowledged = false; pending = heartbeat.cancelRun(queued!.id).then((result) => { acknowledged = true; return result; }); await interrupted; diff --git a/server/src/__tests__/run-trust-preset.test.ts b/server/src/__tests__/run-trust-preset.test.ts index d323f0f2b7..4bf3def655 100644 --- a/server/src/__tests__/run-trust-preset.test.ts +++ b/server/src/__tests__/run-trust-preset.test.ts @@ -53,7 +53,7 @@ it("dispatch retains raw trust before workspace and broker setup", () => { "utf8", ); const start = heartbeat.indexOf( - "const retainedTrust = await resolveAndRetainRunTrustPreset", + "const retainedTrust = await ", ); const end = heartbeat.indexOf( "const config = parseObject(agent.adapterConfig);", @@ -61,6 +61,7 @@ it("dispatch retains raw trust before workspace and broker setup", () => { ); const dispatch = heartbeat.slice(start, end); expect(start).toBeGreaterThan(0); + expect(dispatch).toContain("resolveAndRetainRunTrustPreset(db,"); expect(dispatch).toContain( "executionWorkspacePolicy: projectContext.executionWorkspacePolicy", ); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 17a58cf7a1..f78c656a9b 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -405,7 +405,6 @@ const paperclipRunnerAdapter: ServerAdapterModule = { } if (profile.provider === "acpx") { try { - if (profile.acpxAgent !== "claude") throw new Error("Select Codex to use the native Codex runner."); const target = context.executionTarget; if (target?.kind === "remote") { const probe = await runAdapterExecutionTargetShellCommand( @@ -414,8 +413,8 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ); if (probe.timedOut || probe.exitCode !== 0) throw new Error("Could not verify the remote ACPX runner platform."); const [os, arch] = probe.stdout.trim().split(/\s+/); - if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && ["arm64", "x86_64"].includes(arch ?? "")))) { - throw new Error("ACPX Claude requires Linux x64 or macOS ARM64/x64."); + if (!((os === "Linux" && arch === "x86_64") || (profile.acpxAgent === "claude" && os === "Darwin" && ["arm64", "x86_64"].includes(arch ?? "")))) { + throw new Error(`ACPX ${profile.acpxAgent} requires Linux x64${profile.acpxAgent === "claude" ? " or macOS ARM64/x64" : ""}.`); } return { adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), @@ -423,6 +422,13 @@ const paperclipRunnerAdapter: ServerAdapterModule = { message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], }; } + if (profile.acpxAgent !== "claude") { + return { + adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), + checks: [{ code: "acpx_runtime_unverified", level: "warn" as const, + message: `The ACPX ${profile.acpxAgent} profile is qualified. Runtime package integrity, platform support, and readiness must still be verified by the runner before launch.` }], + }; + } const { probeAcpxClaudeInstallation } = await import("@paperclipai/paperclip-runner/live"); await probeAcpxClaudeInstallation(profile.model); return {