mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
fix: retain warm sandbox bindings independently of worktree settings
Validate live engine coverage and physical sandbox reuse in deployed acceptance. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a5d7df55f6
commit
e8a6fb02bf
7 files changed
+116
-8
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isStagingOrigin, assertDeployedAdapterExclusions } from "./deployed-stack.js";
|
||||
import { isStagingOrigin, assertDeployedAdapterExclusions, deployedAgentEngine } from "./deployed-stack.js";
|
||||
|
||||
describe("deployed stack target", () => {
|
||||
it("requires an explicit HTTPS staging tenant and rejects credential-bearing URLs", () => {
|
||||
@@ -11,6 +11,16 @@ describe("deployed stack target", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("derives engine coverage from live adapter configuration", () => {
|
||||
expect(deployedAgentEngine({ adapterType: "codex_local", adapterConfig: {} })).toBe("cli");
|
||||
expect(deployedAgentEngine({ adapterType: "claude_local", adapterConfig: { engine: "acp" } })).toBe("acp");
|
||||
expect(deployedAgentEngine({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } })).toBe("codex");
|
||||
expect(deployedAgentEngine({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi" } })).toBe("acpx:pi");
|
||||
expect(deployedAgentEngine({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).not.toBe("acpx:pi");
|
||||
expect(() => deployedAgentEngine({ adapterType: "paperclip_runner", adapterConfig: { provider: "acpx" } })).toThrow("Missing");
|
||||
expect(() => deployedAgentEngine({ adapterType: "codex_local", adapterConfig: { engine: "unknown" } })).toThrow("Unknown");
|
||||
});
|
||||
|
||||
it("allows only the four explicitly deferred adapters to be excluded", () => {
|
||||
expect(() => assertDeployedAdapterExclusions([{ adapterType: "cursor", reason: "Explicitly deferred by the user" }])).not.toThrow();
|
||||
for (const adapterType of ["codex_local", "claude_local", "opencode_local", "pi_local", "paperclip_runner"]) {
|
||||
|
||||
@@ -16,6 +16,19 @@ export interface DeployedStack {
|
||||
profiles: Array<{ id: string; adapterType: string; engine: string; model: string; qualification: string; agentId: string }>;
|
||||
}
|
||||
|
||||
/** Derive coverage from the live configuration, never a manifest's label. */
|
||||
export function deployedAgentEngine(agent: { adapterType: string; adapterConfig: Record<string, unknown> }): string {
|
||||
const config = agent.adapterConfig;
|
||||
if (agent.adapterType === "paperclip_runner") {
|
||||
assert(config.provider === "codex" || config.provider === "opencode" || config.provider === "acpx", "Unknown native provider");
|
||||
if (config.provider !== "acpx") return config.provider;
|
||||
assert(typeof config.acpxAgent === "string" && config.acpxAgent.length > 0, "Missing native ACPX engine");
|
||||
return `acpx:${config.acpxAgent}`;
|
||||
}
|
||||
assert(config.engine === undefined || config.engine === "cli" || config.engine === "acp", "Unknown legacy engine");
|
||||
return config.engine === "acp" ? "acp" : "cli";
|
||||
}
|
||||
|
||||
export function loadDeployedStack(): DeployedStack {
|
||||
const filename = process.env.PAPERCLIP_DEPLOYED_STACK_MANIFEST;
|
||||
if (!filename) throw new Error("PAPERCLIP_DEPLOYED_STACK_MANIFEST is required");
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import type { EnvironmentCapabilities } from "../../packages/shared/src/environment-support.js";
|
||||
import type { WorkFolderListing, WorkFolderSyncStatus } from "../../packages/shared/src/work-folders.js";
|
||||
import type { SandboxWorkFolderManifest, WorkFolderListing, WorkFolderSyncStatus } from "../../packages/shared/src/work-folders.js";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
|
||||
import { pollUntil } from "./api.js";
|
||||
import { DeployedStackApi, loadDeployedStack } from "./deployed-stack.js";
|
||||
import { DeployedStackApi, deployedAgentEngine, loadDeployedStack } from "./deployed-stack.js";
|
||||
|
||||
const stack = loadDeployedStack();
|
||||
const api = new DeployedStackApi(stack);
|
||||
@@ -32,13 +32,16 @@ test("deployed candidate and complete supported adapter inventory", async ({}, i
|
||||
if (adapter.capabilities.supportsAcp) required.push(`${adapter.type}:acp`);
|
||||
}
|
||||
}
|
||||
const configured = new Set(stack.profiles.map((profile) => `${profile.adapterType}:${profile.engine}`));
|
||||
expect(required.filter((key) => !configured.has(key)), "Every exposed sandbox adapter/engine requires a qualified fixture").toEqual([]);
|
||||
const configured = new Set<string>();
|
||||
for (const profile of stack.profiles) {
|
||||
const agent = await api.json<{ adapterType: string; adapterConfig: Record<string, unknown> }>(`/api/agents/${profile.agentId}`);
|
||||
expect(agent.adapterType).toBe(profile.adapterType);
|
||||
expect(agent.adapterConfig.model).toBe(profile.model);
|
||||
const engine = deployedAgentEngine(agent);
|
||||
expect(engine, `${profile.id} must exercise its declared live engine`).toBe(profile.engine);
|
||||
configured.add(`${agent.adapterType}:${engine}`);
|
||||
}
|
||||
expect(required.filter((key) => !configured.has(key)), "Every exposed sandbox adapter/engine requires a qualified fixture").toEqual([]);
|
||||
await info.attach("deployed-candidate-and-inventory", { contentType: "application/json", body: Buffer.from(JSON.stringify({ stack, required }, null, 2)) });
|
||||
});
|
||||
|
||||
@@ -83,6 +86,7 @@ for (const profile of stack.profiles) {
|
||||
"In each repo write 'staged' without newline to .acceptance-state, git add ONLY that file, then replace its working-tree content with 'unstaged' without newline. Write 'untracked' without newline to .acceptance-untracked and leave it untracked.",
|
||||
"If .acceptance-setup-count exists, assert it has exactly one line. Never run setup yourself.",
|
||||
`Write exactly '${nonce}' without a newline into $HOME/task/acceptance.txt and $HOME/agent/acceptance-${nonce}.txt.`,
|
||||
`Also write exactly '${nonce}' to $HOME/.cache/warm-${nonce}; this disposable cache marker must survive an actual warm reuse.`,
|
||||
"Then complete this task successfully. Do not print credentials or modify unrelated files.",
|
||||
].join("\n"),
|
||||
});
|
||||
@@ -99,10 +103,15 @@ for (const profile of stack.profiles) {
|
||||
const content = await api.request(`${base}/content?path=acceptance.txt`);
|
||||
expect(content.status).toBe(200); expect(await content.text()).toBe(nonce);
|
||||
const coldRunIds = new Set(cold.saves.map((save) => save.runId));
|
||||
const coldSave = cold.saves.find((save) => !save.active && save.state === "saved")!;
|
||||
const coldRun = await api.json<{ contextSnapshot: { paperclipWorkFolders: SandboxWorkFolderManifest } }>(`/api/heartbeat-runs/${coldSave.runId}`);
|
||||
const coldManifest = coldRun.contextSnapshot.paperclipWorkFolders;
|
||||
expect(coldManifest.sandboxKey).toBeTruthy();
|
||||
await api.json(`/api/issues/${issue.id}`, "PATCH", { status: "todo", description: [
|
||||
"Continue this sandbox acceptance task. This is a warm run; inspect the existing work without repairing it.",
|
||||
"Verify cwd equals HOME and all seven directories still exist.",
|
||||
`Assert $HOME/task/acceptance.txt and every repo's committed HEAD:.acceptance-owner equal '${nonce}'.`,
|
||||
`Assert $HOME/.cache/warm-${nonce} still contains exactly '${nonce}'. A replacement is not a warm pass; do not recreate the marker.`,
|
||||
"For each repo assert HEAD equals the saved task/head-<repo-directory-name>.txt, index :.acceptance-state equals 'staged', working .acceptance-state equals 'unstaged', and .acceptance-untracked equals 'untracked' and remains untracked.",
|
||||
"If .acceptance-setup-count exists, assert exactly one line. Fail with the actual discrepancy; do not recreate missing state or rerun setup.",
|
||||
`Write exactly '${nonce}' without a newline to $HOME/task/warm.txt, then complete the task.`,
|
||||
@@ -117,7 +126,11 @@ for (const profile of stack.profiles) {
|
||||
});
|
||||
const warmContent = await api.request(`${base}/content?path=warm.txt`);
|
||||
expect(warmContent.status).toBe(200); expect(await warmContent.text()).toBe(nonce);
|
||||
await info.attach("cold-and-warm-checkpoints", { contentType: "application/json", body: Buffer.from(JSON.stringify({ cold: cold.saves, warm: warm.saves })) });
|
||||
const warmSave = warm.saves.find((save) => !save.active && save.state === "saved")!;
|
||||
const warmRun = await api.json<{ contextSnapshot: { paperclipWorkFolders: SandboxWorkFolderManifest } }>(`/api/heartbeat-runs/${warmSave.runId}`);
|
||||
const warmManifest = warmRun.contextSnapshot.paperclipWorkFolders;
|
||||
expect(warmManifest.sandboxKey, "Warm acceptance requires the same physical sandbox").toBe(coldManifest.sandboxKey);
|
||||
await info.attach("cold-and-warm-checkpoints", { contentType: "application/json", body: Buffer.from(JSON.stringify({ cold: cold.saves, warm: warm.saves, coldManifest, warmManifest })) });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user