fix: retain supported ACPX readiness checks after integration

Update stop lifecycle fixtures to require verified teardown before acknowledgement.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 15:45:44 -05:00
parent ebfdbfb0b1
commit ea52c83823
4 changed files with 35 additions and 17 deletions

View File

@ -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", () => {

View File

@ -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<typeof adapters.getServerAdapter>);
let pending: Promise<unknown> | 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;

View File

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

View File

@ -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 {