import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AcpRuntimeOptions } from "acpx/runtime"; import type { AdapterExecutionContext, AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils"; import { prepareAdapterExecutionTargetRuntime, startAdapterExecutionTargetPaperclipBridge, startAdapterExecutionTargetProcessSessionBridge, } from "@paperclipai/adapter-utils/execution-target"; // Wrap the staging seam + both sandbox bridges in call-recording spies that // still delegate to the real implementations. This copies the execute.test.ts // harness verbatim so a startup test asserts the exact staging args and bridge // hand-off the engine threads without changing any real behavior. vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => { const actual = await importActual(); return { ...actual, prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime), startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge), startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge), }; }); import { createAcpxEngineExecutor, type AcpxEngineExecutorOptions } from "./execute.js"; import { runChildProcess } from "../server-utils.js"; const tempRoots: string[] = []; async function makeTempRoot() { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-")); tempRoots.push(root); return root; } afterEach(async () => { // A remote run stages a process-session bridge whose detached event writer can // still be flushing a trailing event file into `.../process-sessions//events` // when the run's own best-effort `client.remove(sessionDir)` (which production // catch-wraps) has already returned. Under CI load that trailing write can land // between this recursive delete's directory snapshot and its `rmdir`, surfacing as // `ENOTEMPTY`. `maxRetries`/`retryDelay` make the cleanup ride out that window the // same way production tolerates it, instead of failing the just-passed test. await Promise.all( tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }), ), ); }); async function pathExists(candidate: string): Promise { return fs.access(candidate).then(() => true).catch(() => false); } void pathExists; function createLocalSandboxRunner( onExecute?: (input: { command: string; args?: string[]; cwd?: string; env?: Record; }) => void, ) { let counter = 0; return { execute: async (input: { command: string; args?: string[]; cwd?: string; env?: Record; stdin?: string; timeoutMs?: number; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; }) => { counter += 1; onExecute?.(input); const command = input.command === "bash" ? "/bin/bash" : input.command; return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], { cwd: input.cwd ?? process.cwd(), env: input.env ?? {}, stdin: input.stdin, timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), graceSec: 5, onLog: input.onLog ?? (async () => {}), onSpawn: input.onSpawn ? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt }) : undefined, }); }, }; } function buildRuntime( onSetConfigOption?: (input: { key: string; value: string }) => void, onEnsureSession?: (input: Record) => void, ) { return { ensureSession: async (input: Record) => { onEnsureSession?.(input); return ({ backendSessionId: "backend-session", agentSessionId: "agent-session", runtimeSessionName: "runtime-session", }); }, startTurn: () => ({ events: (async function* () { yield { type: "done", stopReason: "end_turn" }; })(), result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), cancel: async () => {}, }), setConfigOption: async (input: { key: string; value: string }) => { onSetConfigOption?.(input); }, close: async () => {}, }; } async function runExecutor( config: Record, options: { context?: Record; executionTransport?: Record; authToken?: string; executionTarget?: Record; runtimeMcp?: AdapterRuntimeMcpAccess; prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"]; startupTraceContext?: AdapterExecutionContext["startupTraceContext"]; } = {}, ) { const runtimeOptions: Record[] = []; const configOptions: Array<{ key: string; value: string }> = []; const sessionInputs: Record[] = []; const meta: Record[] = []; const logs: Array<{ stream: string; text: string }> = []; const events: Array<{ eventType: string; payload?: Record }> = []; const execute = createAcpxEngineExecutor({ ...(options.prepareRemoteManagedHome ? { prepareRemoteManagedHome: options.prepareRemoteManagedHome } : {}), createRuntime: (options) => { runtimeOptions.push(options as unknown as Record); return buildRuntime( ({ key, value }) => configOptions.push({ key, value }), (input) => sessionInputs.push(input), ) as never; }, }); const result = await execute({ runId: "run-1", agent: { id: "agent-1", companyId: "company-1", }, runtime: {}, config, context: options.context ?? {}, executionTransport: options.executionTransport, authToken: options.authToken, executionTarget: options.executionTarget, runtimeMcp: options.runtimeMcp, startupTraceContext: options.startupTraceContext, onLog: async (stream: "stdout" | "stderr", text: string) => { logs.push({ stream, text }); }, onMeta: async (payload: unknown) => { meta.push(payload as Record); }, onEvent: async (event: { eventType: string; payload?: Record }) => { events.push(event); }, } as never); expect(result.exitCode).toBe(0); return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result }; } // The staging-seam describe helper from execute.test.ts (~:2152). It builds a // runner-backed remote target: the local runner extracts the staged tar into // `remoteCwd`, so the run really ships the HOST worktree into the sandbox. async function setupRemoteSandbox() { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); const localCwd = path.join(root, "worktree"); const remoteCwd = path.join(root, "remote-workspace"); await fs.mkdir(localCwd, { recursive: true }); await fs.mkdir(remoteCwd, { recursive: true }); // A file present only in the HOST worktree proves the workspace is shipped // into the sandbox: the local runner extracts the staged tar into remoteCwd. await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); const runner = createLocalSandboxRunner(); const executionTarget = { kind: "remote", transport: "sandbox", providerKey: "fake-plugin", remoteCwd, runner, }; return { root, stateDir, localCwd, remoteCwd, executionTarget }; } // Read the `configFingerprint` off a settled run result. function fpOf(result: { sessionParams?: unknown }): string | undefined { return (result.sessionParams as { configFingerprint?: string } | undefined)?.configFingerprint; } const okHandle = { backendSessionId: "backend-session", agentSessionId: "agent-session", runtimeSessionName: "runtime-session", }; function completedTurn() { return { events: (async function* () { yield { type: "done", stopReason: "end_turn" }; })(), result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), cancel: async () => {}, }; } describe("ACPX engine startup characterization", () => { // Item 1 + 8: the remote launch env and its finalization point. describe("remote launch environment values", () => { beforeEach(() => vi.clearAllMocks()); it("mints the bridge launch env into the process-session command payload", async () => { const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); // Decode the process-session LAUNCH payload (the base64 command blob). The // in-sandbox process env rides there, not in the exec's own `env`. let launchPayload: Record | null = null; (executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => { if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") { const script = input.args?.[1] ?? ""; const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/); if (match) { launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record< string, unknown >; } } }); await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, { authToken: "real-run-jwt", executionTarget }, ); // The launch payload carries the MERGED paperclip bridge env: the queue // transport mode, a loopback bridge base URL, and a minted bridge token. const payloadEnv = ((launchPayload as Record | null)?.env ?? {}) as Record< string, unknown >; expect(payloadEnv).toMatchObject({ PAPERCLIP_API_BRIDGE_MODE: "queue_v1" }); expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); // The minted bridge token is present and is NOT the host run JWT. expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy(); expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt"); }); it("finalizes the launch env at the bridge merge: the process env carries the merged bridge values", async () => { const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); // Capture the launch payload AND the bridge-channel exec's own `env`. let launchPayload: Record | null = null; let bridgeExecEnv: Record | undefined; (executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => { if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") { bridgeExecEnv = input.env; const script = input.args?.[1] ?? ""; const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/); if (match) { launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record< string, unknown >; } } }); await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, { authToken: "real-run-jwt", executionTarget }, ); // The launch payload is the finalized carrier of the process env. It already // holds the merged bridge values at the point the run emits it, so no later // write mutates the process env after the bridge merge. const payloadEnv = ((launchPayload as Record | null)?.env ?? {}) as Record< string, unknown >; expect(payloadEnv.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy(); // The bridge-channel exec's OWN env is the sandbox transport channel, not the // agent process env: it does not carry the minted agent bridge key. This pins // that the merged agent env lives only in the finalized launch payload. expect(bridgeExecEnv?.PAPERCLIP_SANDBOX_EXEC_CHANNEL).toBe("bridge"); expect(bridgeExecEnv?.PAPERCLIP_API_KEY).toBeUndefined(); }); }); // Item 2: the 17 fingerprint fields folded into `configFingerprint`, and the // outer session key form that embeds it. describe("session fingerprint and session key", () => { beforeEach(() => vi.clearAllMocks()); it("forms the session key as paperclip:company:agent:taskKey:fingerprint and embeds the fingerprint", async () => { const root = await makeTempRoot(); const { result } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd: path.join(root, "workspace"), }); const fp = fpOf(result); expect(fp).toBeTypeOf("string"); expect(fp).toBeTruthy(); // No taskId/issueId/workspaceId in the default context, so taskKey is "default". const sessionKey = (result.sessionParams as { sessionKey?: string }).sessionKey; expect(sessionKey).toBe(`paperclip:company-1:agent-1:default:${fp}`); }); it("keeps the fingerprint stable across two identical runs and a same-config new wake", async () => { const root = await makeTempRoot(); const baseConfig = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd: path.join(root, "workspace"), }; const first = await runExecutor(baseConfig, { context: { taskId: "issue-1", wakeReason: "issue_assigned" }, }); const identical = await runExecutor(baseConfig, { context: { taskId: "issue-1", wakeReason: "issue_assigned" }, }); // A fresh heartbeat with a different wake reason but the same config env. const newWake = await runExecutor(baseConfig, { context: { taskId: "issue-1", wakeReason: "comment", wakeCommentId: "c-9" }, }); expect(fpOf(first.result)).toBeTruthy(); expect(fpOf(identical.result)).toBe(fpOf(first.result)); // Per-wake PAPERCLIP_* churn does not reset the session fingerprint. expect(fpOf(newWake.result)).toBe(fpOf(first.result)); }); it("busts the fingerprint when any representative folded dimension changes", async () => { const root = await makeTempRoot(); const cwd = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const context = { context: { taskId: "issue-1", wakeReason: "issue_assigned" } }; const base = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd }; const baseFp = fpOf((await runExecutor(base, context)).result); expect(baseFp).toBeTruthy(); // Each edit changes exactly one folded dimension and must bust the fingerprint. // agentCommand. expect(fpOf((await runExecutor({ ...base, agentCommand: "node ./other-acp.js" }, context)).result)).not.toBe(baseFp); // cwd. expect(fpOf((await runExecutor({ ...base, cwd: path.join(root, "other-cwd") }, context)).result)).not.toBe(baseFp); // requestedModel. expect(fpOf((await runExecutor({ ...base, model: "some-model" }, context)).result)).not.toBe(baseFp); // requestedThinkingEffort. expect(fpOf((await runExecutor({ ...base, thinkingEffort: "high" }, context)).result)).not.toBe(baseFp); // mode. expect(fpOf((await runExecutor({ ...base, mode: "oneshot" }, context)).result)).not.toBe(baseFp); // adapterEnvHash (a resolved adapter env value). expect(fpOf((await runExecutor({ ...base, env: { FOO: "bar" } }, context)).result)).not.toBe(baseFp); // mcpServers identity (injected runtime MCP set). expect( fpOf( ( await runExecutor(base, { ...context, runtimeMcp: { getServers: () => [ { name: "github", url: "https://x.test/mcp", connectionId: "c-1", token: "t-1" }, ], }, }) ).result, ), ).not.toBe(baseFp); // secretManifestHash. expect( fpOf( ( await runExecutor(base, { ...context, context: { taskId: "issue-1", wakeReason: "issue_assigned", paperclipSecrets: { manifest: [ { configPath: "env.API_TOKEN", envKey: "API_TOKEN", secretId: "secret-1", bindingId: "binding-1", secretKey: "api-token", version: 1, provider: "local_encrypted", }, ], }, }, }) ).result, ), ).not.toBe(baseFp); // additionalSourcesIdentity (referenced-project set). expect( fpOf( ( await runExecutor(base, { ...context, context: { taskId: "issue-1", wakeReason: "issue_assigned", paperclipWorkspace: { cwd, realization: { additional: [ { path: "/host/project-a", projectId: "a", projectWorkspaceId: "ws-a", repoUrl: "https://example.test/a.git", repoRef: "ref-a-1", }, ], }, }, }, }) ).result, ), ).not.toBe(baseFp); // fastMode folds only for codex, so pin it with a codex-vs-codex pair. const codexBase = { agent: "codex", agentCommand: "node ./fake-acp.js", stateDir, cwd }; const codexFp = fpOf((await runExecutor(codexBase, context)).result); expect(fpOf((await runExecutor({ ...codexBase, fastMode: true }, context)).result)).not.toBe(codexFp); }); }); // Item 3: the staging seam call, its arguments, and its order (workspace then // assets, serial). Modeled on the PR-1 staging-seam tests. describe("staging seam calls, arguments, and order", () => { beforeEach(() => vi.clearAllMocks()); it("stages the host workspace with no assets, exactly once, before the process launch", async () => { const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox(); const { sessionInputs, events } = await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, { authToken: "real-run-jwt", executionTarget }, ); // The staging seam crossed exactly once. expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1); const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0]; // The HOST worktree is shipped first; no per-adapter home asset in this lane. expect(stageArgs.workspaceLocalDir).toBe(localCwd); expect(stageArgs.assets ?? []).toEqual([]); expect(stageArgs.installCommand ?? null).toBeNull(); expect(stageArgs.target).toMatchObject({ kind: "remote", transport: "sandbox" }); // The workspace really landed in the sandbox workspace dir. await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi"); // A per-step timing event proves the sync ran inside its timed boundary. const stageEvent = events.find( (event) => event.eventType === "run.startup.step" && event.payload?.step === "stage.sync", ); expect(stageEvent).toBeTruthy(); // And session/new binds to the in-sandbox workspace cwd the seam returned. expect(sessionInputs[0]?.cwd).toBe(remoteCwd); }); it("threads a managed-home asset through the same seam after the workspace", async () => { const { root, stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); const managedHomeDir = path.join(root, "managed-home"); await fs.mkdir(managedHomeDir, { recursive: true }); await fs.writeFile(path.join(managedHomeDir, "config.json"), "{}", "utf8"); await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, { authToken: "real-run-jwt", executionTarget, prepareRemoteManagedHome: async (input) => { const stagedRuntime = await input.stage([ { key: "home", localDir: managedHomeDir, followSymlinks: true }, ]); return { stagedRuntime }; }, }, ); // The seam's home asset is threaded through the SAME staging seam, keyed by // the workspace-local dir. Workspace ships first; the asset rides alongside it. const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0]; expect(stageArgs.workspaceLocalDir).toBe(localCwd); expect(stageArgs.assets).toEqual([ { key: "home", localDir: managedHomeDir, followSymlinks: true }, ]); }); }); // Item 4: the two-bridge overlap and the ACP-initialization ordering. describe("two-bridge overlap and ACP initialization order", () => { beforeEach(() => vi.clearAllMocks()); it("defers the process bridge env, shares one runtimeRootDir, and runs session/new on the sandbox cwd", async () => { const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox(); const { sessionInputs, runtimeOptions } = await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, { authToken: "real-run-jwt", executionTarget }, ); // The process-session bridge receives its launch env as a DEFERRED thunk, the // seam that lets its env-independent setup overlap the paperclip bridge start. const processArgs = vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mock.calls[0]![0]; expect(typeof processArgs.env).toBe("function"); // Both bridges receive the SAME real (non-null) runtimeRootDir from staging. const paperclipArgs = vi.mocked(startAdapterExecutionTargetPaperclipBridge).mock.calls[0]![0]; expect(paperclipArgs.runtimeRootDir).toBeTruthy(); expect(String(paperclipArgs.runtimeRootDir)).toContain(".paperclip-runtime"); expect(processArgs.runtimeRootDir).toBe(paperclipArgs.runtimeRootDir); // The ACP runtime + session/new both bind to the in-sandbox workspace cwd, // which the run resolves only after the bridges bring the sandbox up. expect(runtimeOptions[0]?.cwd).toBe(remoteCwd); expect(sessionInputs[0]?.cwd).toBe(remoteCwd); expect(sessionInputs[0]?.cwd).not.toBe(localCwd); }); }); // Item 5 + 6: every startup exit path, its result phase, and the cleanup-call // set (bridges stop / lease releases / runtime closes). describe("startup exit paths and cleanup", () => { beforeEach(() => vi.clearAllMocks()); it("create_runtime failure: settles an error result, stops both bridges, releases the lease", async () => { const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); const paperclipStop = vi.fn(async () => {}); const processStop = vi.fn(async () => {}); vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce( async () => ({ env: {}, stop: paperclipStop }) as never, ); vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce( async () => ({ agentCommand: null, stop: processStop }) as never, ); const stagingLocks = new Map>(); const execute = createAcpxEngineExecutor({ stagingLocks, warmHandles: new Map(), stagedRuntimes: new Map(), createRuntime: () => { throw new Error("createRuntime boom"); }, }); const result = await execute({ runId: "run-create-fail-remote", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, context: {}, authToken: "real-run-jwt", executionTarget, onLog: async () => {}, onMeta: async () => {}, onEvent: async () => {}, } as never); // The post-build runtime-creation failure returns a settled error result. expect(result.exitCode).toBe(1); expect(result.resultJson?.phase).toBe("create_runtime"); // Both live bridges stop exactly once and the per-session lease releases. expect(paperclipStop).toHaveBeenCalledTimes(1); expect(processStop).toHaveBeenCalledTimes(1); expect(stagingLocks.size).toBe(0); }); it("partial-bridge failure: throws and stops the concurrently-started bridge exactly once", async () => { const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); const stop = vi.fn(async () => {}); vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => { throw new Error("paperclip bridge boom"); }); vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce( async () => ({ agentCommand: null, stop }) as never, ); const execute = createAcpxEngineExecutor({ createRuntime: () => buildRuntime() as never, }); // A partial bridge failure inside buildRuntime is one of the only two throw // paths, so the run rethrows instead of settling a result. await expect( execute({ runId: "run-bridge-fail", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, context: {}, authToken: "real-run-jwt", executionTarget, onLog: async () => {}, onMeta: async () => {}, onEvent: async () => {}, } as never), ).rejects.toThrow("paperclip bridge boom"); // The concurrently-started process-session bridge was stopped exactly once. expect(stop).toHaveBeenCalledTimes(1); }); it("cold ensure_session throw: ensure_session error, and the runtime closes (the cold-handshake leak is fixed)", async () => { const root = await makeTempRoot(); const closeSpy = vi.fn(async () => {}); const execute = createAcpxEngineExecutor({ createRuntime: () => ({ ensureSession: async () => { throw new Error("ensureSession boom"); }, startTurn: () => completedTurn(), close: closeSpy, }) as never, }); const result = await execute({ runId: "handshake-fail", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") }, context: {}, onLog: async () => {}, onMeta: async () => {}, } as never); expect(result.exitCode).toBe(1); expect(result.resultJson?.phase).toBe("ensure_session"); // The runtime enters the ledger the moment it is created, so a cold // `ensureSession` throw (before any handle exists) still closes it through the // settlement `endSession` step. This closes the former cold-handshake leak. expect(closeSpy).toHaveBeenCalledTimes(1); }); it("missing session handle: ensure_session error and the minimal runtime closes", async () => { const root = await makeTempRoot(); const closeSpy = vi.fn(async () => {}); const execute = createAcpxEngineExecutor({ createRuntime: () => ({ // A runtime that returns no session handle drives the missing-handle path. ensureSession: async () => undefined, startTurn: () => completedTurn(), close: closeSpy, }) as never, }); const result = await execute({ runId: "missing-handle", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state") }, context: {}, onLog: async () => {}, onMeta: async () => {}, } as never); expect(result.exitCode).toBe(1); expect(result.resultJson?.phase).toBe("ensure_session"); expect(result.errorCode).toBe("acpx_runtime_error"); expect(closeSpy).toHaveBeenCalledTimes(1); }); it("configure_session failure: configure_session error and the runtime closes", async () => { const root = await makeTempRoot(); const closeSpy = vi.fn(async () => {}); const execute = createAcpxEngineExecutor({ createRuntime: () => ({ ensureSession: async () => okHandle, // Gemini model/effort drive a session config option; a throwing setter // fails the configure_session phase after the handshake succeeds. setConfigOption: async () => { throw new Error("setConfigOption boom"); }, startTurn: () => completedTurn(), close: closeSpy, }) as never, }); const result = await execute({ runId: "configure-fail", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, config: { agent: "gemini", model: "gemini-2.5-pro", thinkingEffort: "high", stateDir: path.join(root, "state"), }, context: {}, onLog: async () => {}, onMeta: async () => {}, } as never); expect(result.exitCode).toBe(1); expect(result.resultJson?.phase).toBe("configure_session"); expect(closeSpy).toHaveBeenCalledTimes(1); }); it("persistent host failure: the first run closes and relaunches, so a failing second run re-creates and closes", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); const startedAt = "2026-01-01T00:00:00.000Z"; const closeSpy = vi.fn(async () => {}); let created = 0; const warmHandles = new Map(); const execute = createAcpxEngineExecutor({ warmHandles, createRuntime: (options) => { created += 1; const opts = options as AcpRuntimeOptions & { onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise; }; return { ensureSession: async () => { await opts.onAgentSpawn?.({ pid: 4242, startedAt }); return okHandle; }, startTurn: () => completedTurn(), close: closeSpy, } as never; }, }); const config = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, mode: "persistent", warmHandleIdleMs: 60_000, }; const first = await execute({ runId: "warm-1", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, config, context: {}, onLog: async () => {}, onMeta: async () => {}, onSpawn: async () => {}, } as never); expect(first.exitCode).toBe(0); // Amendment B: the first clean persistent turn closes and relaunches instead // of warm-saving the runtime, so no warm handle survives. expect(warmHandles.size).toBe(0); expect(created).toBe(1); // The second run finds no warm handle, so it re-creates the runtime and fails // while persisting process identity during ensure_session. const second = await execute({ runId: "warm-2", agent: { id: "agent-1", companyId: "company-1" }, runtime: { sessionParams: first.sessionParams }, config, context: {}, onLog: async () => {}, onMeta: async () => {}, onSpawn: async () => { throw new Error("onSpawn boom"); }, } as never); // The second run built runtime #2 (no warm reuse), closed it on the failure, // and reported the ensure_session phase. Both runtimes closed (one per run). expect(created).toBe(2); expect(second.exitCode).toBe(1); expect(second.resultJson?.phase).toBe("ensure_session"); expect(closeSpy).toHaveBeenCalledTimes(2); expect(warmHandles.size).toBe(0); }); }); // Item 7: the per-lane resource set. describe("per-lane resource set", () => { beforeEach(() => vi.clearAllMocks()); it("local lane: crosses no staging seam, starts no bridge, keeps session/new on the host cwd", async () => { const root = await makeTempRoot(); const localCwd = path.join(root, "worktree"); await fs.mkdir(localCwd, { recursive: true }); const { sessionInputs, runtimeOptions } = await runExecutor({ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd: localCwd, }); expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).not.toHaveBeenCalled(); expect(vi.mocked(startAdapterExecutionTargetPaperclipBridge)).not.toHaveBeenCalled(); expect(vi.mocked(startAdapterExecutionTargetProcessSessionBridge)).not.toHaveBeenCalled(); expect(sessionInputs[0]?.cwd).toBe(localCwd); expect(runtimeOptions[0]?.cwd).toBe(localCwd); }); it("persistent host lane: closes and relaunches the handle, so a second run re-creates the runtime", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); let created = 0; const warmHandles = new Map(); const execute = createAcpxEngineExecutor({ warmHandles, createRuntime: () => { created += 1; return buildRuntime() as never; }, }); const config = { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, mode: "persistent", warmHandleIdleMs: 60_000, }; const base = { agent: { id: "agent-1", companyId: "company-1" }, config, context: {}, onLog: async () => {}, onMeta: async () => {}, onSpawn: async () => {}, }; const first = await execute({ runId: "host-warm-1", runtime: {}, ...base } as never); const second = await execute({ runId: "host-warm-2", runtime: { sessionParams: first.sessionParams }, ...base, } as never); expect(first.exitCode).toBe(0); expect(second.exitCode).toBe(0); // Amendment B: the host lane never warm-saves a live runtime (the run-minted // API key is never revoked), so the first run closes and relaunches. No warm // handle survives, so the second run re-creates runtime #2. expect(created).toBe(2); expect(warmHandles.size).toBe(0); }); it("remote process-session lane: does NOT warm-save the handle, so a second run re-creates the runtime", async () => { const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); let created = 0; const ensureInputs: Array> = []; const warmHandles = new Map(); const execute = createAcpxEngineExecutor({ warmHandles, stagedRuntimes: new Map(), stagingLocks: new Map(), createRuntime: () => { created += 1; return buildRuntime(undefined, (input) => ensureInputs.push(input)) as never; }, }); const base = { agent: { id: "agent-1", companyId: "company-1" }, config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd, mode: "persistent", warmHandleIdleMs: 60_000, }, context: {}, authToken: "real-run-jwt", executionTarget, onLog: async () => {}, onMeta: async () => {}, onEvent: async () => {}, }; const first = await execute({ runId: "remote-warm-1", runtime: {}, ...base } as never); const second = await execute({ runId: "remote-warm-2", runtime: { sessionParams: first.sessionParams }, ...base, } as never); expect(first.exitCode).toBe(0); expect(second.exitCode).toBe(0); // The remote process-session lane never keeps the handle warm, so the second // run re-creates the runtime and runs a fresh handshake instead of reusing one. expect(created).toBe(2); expect(warmHandles.size).toBe(0); expect(ensureInputs).toHaveLength(2); }); }); });