diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c7dc8f46975..9f9a070363c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -4174,6 +4174,15 @@ capabilities, stable keys, operating limits, automatic history migration and qua Do not remove a web volume until its complete retained evidence inventory and the independent disposable-server requirements are satisfied. +## Volume-free execution admission + +With `PAPERCLIP_EPHEMERAL_SERVER=true`, workspace preparation requires Paperclip +Runner and remote workspace storage. A fresh run still has its initial `legacy` +label at this phase; that label is not the selected execution mode. The runtime +selector checks the resolved native warm lifecycle before admission and rejects +legacy or per-turn execution. External conversation providers keep their existing +exemption because they own no task filesystem. + ## Cloud startup configuration Cloud stores nonsecret startup choices in a private PostgreSQL singleton. diff --git a/server/src/__tests__/ephemeral-server-contract.test.ts b/server/src/__tests__/ephemeral-server-contract.test.ts index 72c319fac84..bfa5e4acae8 100644 --- a/server/src/__tests__/ephemeral-server-contract.test.ts +++ b/server/src/__tests__/ephemeral-server-contract.test.ts @@ -26,14 +26,15 @@ it("never connects the listener with migration privileges, another tenant, or th }); it("rejects disk-dependent execution and instructions while retaining remote warm execution", () => { vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", "true"); - const remote = { externalConversation: false, native: true, adapterType: "paperclip_runner", pluginTaskWorkspace: false, remoteAuthority: true }; + const remote = { externalConversation: false, adapterType: "paperclip_runner", pluginTaskWorkspace: false, remoteAuthority: true }; expect(() => assertEphemeralWorkspaceExecution(remote)).not.toThrow(); expect(() => assertEphemeralWorkspaceExecution({ ...remote, remoteAuthority: false, pluginTaskWorkspace: true })).not.toThrow(); - for (const patch of [{ native: false }, { remoteAuthority: false }, { adapterType: "process" }]) { + for (const patch of [{ remoteAuthority: false }, { adapterType: "process" }]) { expect(() => assertEphemeralWorkspaceExecution({ ...remote, ...patch })).toThrow("warm Paperclip Runner"); } expect(() => assertEphemeralNativeLifecycle("warm")).not.toThrow(); expect(() => assertEphemeralNativeLifecycle("per_turn")).toThrow("warm Paperclip Runner"); + expect(() => assertEphemeralNativeLifecycle(undefined)).toThrow("warm Paperclip Runner"); const agent = { id: "agent", companyId: "company", name: "Agent", adapterConfig: {} }; expect(() => assertEphemeralAgentInstructions(agent)).not.toThrow(); expect(() => assertEphemeralAgentInstructions({ ...agent, adapterConfig: { instructionsBundleMode: "external", instructionsRootPath: "/old-volume" } })).toThrow("managed agent instructions"); diff --git a/server/src/services/heartbeat/runtime-selection.test.ts b/server/src/services/heartbeat/runtime-selection.test.ts index 7b777887f85..f5d4603d050 100644 --- a/server/src/services/heartbeat/runtime-selection.test.ts +++ b/server/src/services/heartbeat/runtime-selection.test.ts @@ -328,6 +328,17 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () = expect(await coordinators(run.id)).toHaveLength(0); }); + it("rejects resolved legacy execution on an ephemeral server before runtime admission", async () => { + const { input, run } = await fixture("process"); + vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", "true"); + expect(input.nativeRuntimeResolution.kind).toBe("legacy"); + await expect(selectHeartbeatRuntime(db, input)).rejects.toThrow("warm Paperclip Runner"); + expect((await read(run.id)).runtimeMode).toBe("legacy"); + expect(await coordinators(run.id)).toHaveLength(0); + expect(input.onNativeLifecycleSelected).not.toHaveBeenCalled(); + expect(prepareNativeWorkspaceSync).not.toHaveBeenCalled(); + }); + it.each(["warm", "per_turn"] as const)("checks resolved %s policy for ephemeral admission", async mode => { const { input, run } = await fixture(); vi.stubEnv("PAPERCLIP_EPHEMERAL_SERVER", "true"); diff --git a/server/src/services/heartbeat/runtime-selection.ts b/server/src/services/heartbeat/runtime-selection.ts index 4f10a467302..49d96cf762b 100644 --- a/server/src/services/heartbeat/runtime-selection.ts +++ b/server/src/services/heartbeat/runtime-selection.ts @@ -267,6 +267,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele const { runtimeConfig, runtimeSkillEntries, runScopedMentionedSkillKeys, instructionCopy, agentIdentity, issueAssigneeOverrides, resolvedConfig, managedAiRuntime } = input.config; const runOptions = input.recovery; const { providerTraceRequested, providerTraceCapture } = input.trace; + if (nativeRuntimeResolution.kind !== "native" && !isDotRun) assertEphemeralNativeLifecycle(undefined); let nativeExecution: NativeExecutionInput | null = null; let getNativeFreshSessionHandoff: (() => Promise) | undefined; let nativeRunnerInstanceId: string | null = null; diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 5aa66c1d9cd..08077429437 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -170,14 +170,15 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace }); it.each([ - { kind: "empty", repoUrl: null, branchMode: null, fresh: false }, + { kind: "empty", repoUrl: null, branchMode: null, fresh: false, ephemeral: true }, { kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: null, fresh: false }, { kind: "git", repoUrl: "ssh://git@github.com/example/remote.git", branchMode: null, fresh: false }, { kind: "git", repoUrl: "git@github.com:example/remote.git", branchMode: null, fresh: false }, { kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "existing", fresh: false }, { kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "create", fresh: false }, { kind: "git", repoUrl: "https://github.com/example/remote.git", branchMode: "create", fresh: true }, - ] as const)("reserves a $kind remote workspace ($repoUrl, branch: $branchMode, fresh: $fresh) without materializing a controller checkout", async ({ kind, repoUrl, branchMode, fresh }) => { + ] as const)("reserves a $kind remote workspace ($repoUrl, branch: $branchMode, fresh: $fresh) without materializing a controller checkout", async (scenario) => { + const { kind, repoUrl, branchMode, fresh } = scenario; const { input, source, issue, envOrchestrator } = await fixture(); const [savedEnvironment] = await db.insert(environments).values({ name: `Remote ${randomUUID()}`, driver: "sandbox", status: "active", config: { provider: "daytona" } }).returning(); @@ -227,10 +228,13 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace throw new Error("reached remote acquisition"); }); const configured = { DATABASE_URL: database.connectionString, PAPERCLIP_STORAGE_PROVIDER: "s3", - PAPERCLIP_SECRETS_MASTER_KEY: "remote-workspace-preparation-fixture" }; + PAPERCLIP_SECRETS_MASTER_KEY: "remote-workspace-preparation-fixture", + PAPERCLIP_EPHEMERAL_SERVER: "ephemeral" in scenario ? "true" : "false" }; const previous = new Map(Object.keys(configured).map(key => [key, process.env[key]])); Object.assign(process.env, configured); try { + // Fresh runs are still marked legacy until the runtime selector owns promotion. + expect(input.run.runtimeMode).toBe("legacy"); await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("reached remote acquisition"); expect(resolve).not.toHaveBeenCalled(); expect(repositoryPreparation).not.toHaveBeenCalled(); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 15c91735f71..665a070e835 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -241,7 +241,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace && !projectExecutionWorkspacePolicy?.storageProviderPluginId, existingWorkspace: reusableExistingExecutionWorkspace ?? existingExecutionWorkspace, }); - assertEphemeralWorkspaceExecution({ externalConversation: isDotRun, native: run.runtimeMode === "native", + assertEphemeralWorkspaceExecution({ externalConversation: isDotRun, adapterType: agent.adapterType, pluginTaskWorkspace, remoteAuthority }); const assertRemotePreparationOwned = run.runtimeMode === "native" ? () => assertNativeSessionPreparationAuthority({ diff --git a/server/src/services/remote-workspaces.ts b/server/src/services/remote-workspaces.ts index 61776878ae0..37c41fd330e 100644 --- a/server/src/services/remote-workspaces.ts +++ b/server/src/services/remote-workspaces.ts @@ -76,11 +76,13 @@ export function shouldUseRemoteWorkspaces(input: { * remote controller may replace its local projection; host-owned execution * cannot make that promise. External conversation providers own no task disk. */ export function assertEphemeralWorkspaceExecution(input: { - externalConversation: boolean; native: boolean; adapterType: string; + externalConversation: boolean; adapterType: string; pluginTaskWorkspace: boolean; remoteAuthority: boolean; }) { if (process.env.PAPERCLIP_EPHEMERAL_SERVER !== "true" || input.externalConversation) return; - if (!input.native || input.adapterType !== "paperclip_runner" + // Runtime selection has not promoted a fresh run yet. Validate storage here; + // the runtime selector separately admits only its resolved warm native policy. + if (input.adapterType !== "paperclip_runner" || (!input.pluginTaskWorkspace && !input.remoteAuthority)) { throw unprocessable("Volume-free servers require warm Paperclip Runner execution with remote workspace storage.", { code: "ephemeral_execution_configuration_required" }); @@ -89,7 +91,7 @@ export function assertEphemeralWorkspaceExecution(input: { /** The runtime selector calls this after environment, credentials and saved * execution policy have resolved. Raw agent settings are not lifecycle authority. */ -export function assertEphemeralNativeLifecycle(mode: "warm" | "per_turn") { +export function assertEphemeralNativeLifecycle(mode: "warm" | "per_turn" | undefined) { if (process.env.PAPERCLIP_EPHEMERAL_SERVER === "true" && mode !== "warm") { throw unprocessable("Volume-free servers require warm Paperclip Runner execution with remote workspace storage.", { code: "ephemeral_execution_configuration_required" });