mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
fix(server): check ephemeral runtime after native selection
Fresh runs retain their initial legacy label during workspace preparation. Check remote storage authority there and validate the resolved native warm lifecycle in runtime selection. Reject actual legacy execution before admission. Reproduced both boundaries before the fix. All 156 focused tests, workspace typecheck and build pass. A real Codex/Daytona fixture completed two native runs; full overlap qualification continues separately. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e122478368
commit
8f44c1370b
7 files changed
+37
-9
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string | null>) | undefined;
|
||||
let nativeRunnerInstanceId: string | null = null;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in new issue
Block a user