diff --git a/server/src/services/heartbeat/workspace-preparation.test.ts b/server/src/services/heartbeat/workspace-preparation.test.ts index 08077429437..beb43a34b0f 100644 --- a/server/src/services/heartbeat/workspace-preparation.test.ts +++ b/server/src/services/heartbeat/workspace-preparation.test.ts @@ -52,7 +52,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace let database: Awaited>; let db: Db; let home: string; - const persistenceKeys = ["DATABASE_URL", "PAPERCLIP_STORAGE_PROVIDER", "PAPERCLIP_SECRETS_MASTER_KEY", "PAPERCLIP_SECRETS_MASTER_KEY_FILE"]; + const persistenceKeys = ["PAPERCLIP_EPHEMERAL_SERVER", "DATABASE_URL", "PAPERCLIP_STORAGE_PROVIDER", "PAPERCLIP_SECRETS_MASTER_KEY", "PAPERCLIP_SECRETS_MASTER_KEY_FILE"]; let persistenceBefore = new Map(); const configureRemotePersistence = () => Object.assign(process.env, { DATABASE_URL: database.connectionString, PAPERCLIP_STORAGE_PROVIDER: "s3", PAPERCLIP_SECRETS_MASTER_KEY: "remote-workspace-preparation-fixture" }); @@ -272,7 +272,7 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace expect(await db.select().from(executionWorkspaces)).toHaveLength(0); }); - it("auto-enrollment leaves a configured local folder on the host authority", async () => { + it.each([false, true])("configured local folders retain host authority only on durable servers (ephemeral=%s)", async ephemeral => { const { input, envOrchestrator } = await fixture(); input.agent.adapterType = "paperclip_runner"; input.environment.selectedEnvironmentForConfig = { ...input.environment.localEnvironment, @@ -281,8 +281,11 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace const acquire = vi.spyOn(envOrchestrator, "acquireForRun"); configureRemotePersistence(); const resolve = vi.spyOn(input.services, "resolveWorkspaceForRun").mockRejectedValue(new Error("local folder retained")); - await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("local folder retained"); - expect(resolve).toHaveBeenCalledOnce(); + process.env.PAPERCLIP_EPHEMERAL_SERVER = String(ephemeral); + await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow(ephemeral + ? "Volume-free servers require warm Paperclip Runner execution with remote workspace storage." : "local folder retained"); + if (ephemeral) expect(resolve).not.toHaveBeenCalled(); + else expect(resolve).toHaveBeenCalledOnce(); expect(await db.select().from(executionWorkspaces)).toHaveLength(0); expect(acquire).not.toHaveBeenCalled(); }); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 665a070e835..e4c76adb48b 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -322,7 +322,13 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace issue: issueRef, agent, projectId: issueRef!.projectId, repoRef: null, })) : null), }); - if (sourceState.authority === "host") remoteAuthority = false; + if (sourceState.authority === "host") { + remoteAuthority = false; + // Source resolution can retain a configured host folder. Check the + // resolved authority before materializing it or acquiring a sandbox. + assertEphemeralWorkspaceExecution({ externalConversation: isDotRun, + adapterType: agent.adapterType, pluginTaskWorkspace, remoteAuthority }); + } else { remoteSourceState.value = sourceState; if (!enrolledRemoteAuthority) await assertRemotePreparationOwned();