mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
Integrate immutable Boat instruction snapshots
Co-Authored-By: Paperclip <noreply@paperclip.ing> * commit '469da8b2c2': fix(boat): stage immutable context per bootstrap attempt Document provider cache computer-owner capability requirement
This commit is contained in:
commit
4e3cf6b07e
3 files changed
+91
-5
No files matched your search
@@ -13977,3 +13977,79 @@ describe("computer graceful idle reservation", () => {
|
||||
else expect(detach).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computer managed context snapshots", () => {
|
||||
it("stages a retry into fresh immutable references without touching earlier snapshots or provider HOME", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "computer-context-retry-"));
|
||||
const source = join(root, "source"), agentHome = join(root, "agent-home");
|
||||
await mkdir(source); await mkdir(agentHome);
|
||||
await writeFile(join(source, "AGENTS.md"), "snapshot instruction", { mode: 0o400 });
|
||||
await chmod(source, 0o500);
|
||||
const remoteExecute = async (command: { command: string; args?: string[]; stdin?: string }) => ({
|
||||
exitCode: 0, timedOut: false, signal: null, stderr: "",
|
||||
stdout: execFileSync(command.command, command.args, { input: command.stdin, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
});
|
||||
const target = { kind: "remote", transport: "computer", environmentId: "snapshot-env", remoteCwd: agentHome,
|
||||
fileAuthority: { kind: "remote-persistent", agentHome, root: agentHome, placementId: "placement" },
|
||||
runner: { execute: remoteExecute }, processRunner: { execute: remoteExecute } };
|
||||
const sourceContext = nativeRuntimeContextFixture();
|
||||
sourceContext.instructions.bundle.rootPath = source;
|
||||
const run = { ...execution, binding: { ...execution.binding, agentId: "snapshot-agent", runId: "snapshot-retry" },
|
||||
runtimeContext: sourceContext };
|
||||
const previousStateDirectory = process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
||||
const contexts: ReturnType<typeof nativeRuntimeContextFixture>[] = [];
|
||||
const homes: string[] = [];
|
||||
try {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
// Factory-only fixture has no live PRP controller; isolate its local
|
||||
// journals while retaining the exact same remote session and run.
|
||||
process.env.PAPERCLIP_RUNNER_STATE_DIR = join(root, `controller-${attempt}`);
|
||||
await createRunnerdBackend({ db: leaseDb(run), execution: run, runnerInstanceId: "snapshot-runner", runnerExecutionTarget: target as never });
|
||||
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
|
||||
const transport = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & {
|
||||
runnerRuntimeContext: ReturnType<typeof nativeRuntimeContextFixture>;
|
||||
};
|
||||
contexts.push(transport.runnerRuntimeContext);
|
||||
const destination = transport.runnerRuntimeContext.instructions.bundle.rootPath;
|
||||
expect(destination).toMatch(/\/context\/snapshots\/[0-9a-f-]+\/instructions$/);
|
||||
expect(transport.runnerRuntimeContext.instructions.entryPath).toBe("AGENTS.md");
|
||||
const home = remoteRunnerStorageRoots(target as never, run.session.normalizedSessionId!).sessionRoot + "/filesystem/codex-home";
|
||||
homes.push(home);
|
||||
await mkdir(home, { recursive: true });
|
||||
if (attempt === 0) await writeFile(join(home, "provider-session"), "persistent conversation");
|
||||
await stageRemoteRunnerDirectory({ target: target as never, runner: target.runner as never, sourcePath: source, targetPath: destination, mode: 0o500 });
|
||||
expect(await readFile(join(destination, "AGENTS.md"), "utf8")).toBe("snapshot instruction");
|
||||
expect((await lstat(destination)).mode & 0o777).toBe(0o500);
|
||||
expect(await readFile(join(home, "provider-session"), "utf8")).toBe("persistent conversation");
|
||||
}
|
||||
expect(contexts[0]!.instructions.bundle.rootPath).not.toBe(contexts[1]!.instructions.bundle.rootPath);
|
||||
expect(homes[0]).toBe(homes[1]);
|
||||
expect((await lstat(contexts[0]!.instructions.bundle.rootPath)).mode & 0o777).toBe(0o500);
|
||||
expect(await readFile(join(contexts[0]!.instructions.bundle.rootPath, "AGENTS.md"), "utf8")).toBe("snapshot instruction");
|
||||
} finally {
|
||||
if (previousStateDirectory === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
||||
else process.env.PAPERCLIP_RUNNER_STATE_DIR = previousStateDirectory;
|
||||
execFileSync("chmod", ["-R", "u+w", root]);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["prepare-failed", "dispatch-lost-ack"])("fences prelaunch retry at the physical launch boundary (%s)", async condition => {
|
||||
let dispatched = false;
|
||||
const retire = vi.fn(async () => true);
|
||||
const inspectProcess = vi.fn(async () => ({ running: false, claim: null }));
|
||||
const launch = vi.fn(async () => { throw new Error("lost launch acknowledgement"); });
|
||||
const target = { kind: "remote", transport: "computer", remoteCwd: "/remote", launch, retire, inspectProcess };
|
||||
const launcher = createRemoteRunnerProcessLauncher({ target: target as never, runner: { execute: vi.fn() } as never,
|
||||
remoteBinary: "/runtime/runnerd", processIdentityPath: "/runtime/identity", stateDirectory: "/runtime/state",
|
||||
diagnosticsDirectory: "/runtime/diagnostics", runnerInstanceId: "physical-launch",
|
||||
ensureArtifact: async () => { if (condition === "prepare-failed") throw new Error("read-only context staging failed"); },
|
||||
onLaunchAttempt: () => { dispatched = true; },
|
||||
});
|
||||
const handle = launcher({ command: "/controller/runnerd", args: [], cwd: "/controller", environment: {} });
|
||||
await expect(handle.completion).rejects.toThrow(condition === "prepare-failed" ? "context staging failed" : "lost launch acknowledgement");
|
||||
await retireUnlaunchedComputerAttempt(target as never, dispatched);
|
||||
if (condition === "prepare-failed") { expect(launch).not.toHaveBeenCalled(); expect(retire).toHaveBeenCalledOnce(); }
|
||||
else { expect(launch).toHaveBeenCalledOnce(); expect(inspectProcess).not.toHaveBeenCalled(); expect(retire).not.toHaveBeenCalled(); }
|
||||
});
|
||||
});
|
||||
@@ -11262,7 +11262,6 @@ export function createRemoteRunnerProcessLauncher(input: {
|
||||
}): (spec: RunnerProcessLaunchSpec) => RunnerProcessHandle {
|
||||
const runner = input.runner;
|
||||
return (spec) => {
|
||||
input.onLaunchAttempt?.();
|
||||
let launchedIdentity: {
|
||||
nonce: string;
|
||||
pid: number;
|
||||
@@ -11353,6 +11352,9 @@ export function createRemoteRunnerProcessLauncher(input: {
|
||||
// into its own session instead; its own bounded diagnostics directory and
|
||||
// durable PRP state remain the authorities, and the controller monitors
|
||||
// the exact persisted process identity below.
|
||||
// From this exact boundary onward, a lost RPC acknowledgement may mean
|
||||
// a live process. Asset preparation failures above prove no dispatch.
|
||||
input.onLaunchAttempt?.();
|
||||
const launchResult = input.target.transport === "computer"
|
||||
? await input.target.launch({ command: "sh", args: ["-c", 'mkdir -p -- "$(dirname -- "$1")"; ' + REMOTE_RUNNER_CHILD_LAUNCH_SCRIPT,
|
||||
"paperclip-runner-child", input.processIdentityPath, identityNonce, input.runnerInstanceId,
|
||||
@@ -12024,6 +12026,13 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
: null;
|
||||
const sourceRuntimeContext =
|
||||
"runtimeContext" in input.execution ? input.execution.runtimeContext : null;
|
||||
// Computer bundles are immutable. Each bootstrap attempt publishes new
|
||||
// references instead of overlaying a previous read-only snapshot. Old
|
||||
// snapshots remain until future bounded maintenance; provider HOME and user
|
||||
// files never participate in this materialization.
|
||||
const remoteContextRoot = remoteRunnerFilesystemRoot
|
||||
? posix.join(remoteRunnerFilesystemRoot, "context", ...(remoteTarget?.transport === "computer" ? ["snapshots", randomUUID()] : []))
|
||||
: null;
|
||||
const remoteRuntimeContext: NativeRuntimeContextSnapshot | null =
|
||||
remoteRunnerFilesystemRoot && sourceRuntimeContext
|
||||
? {
|
||||
@@ -12036,8 +12045,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
bundle: {
|
||||
...sourceRuntimeContext.instructions.bundle,
|
||||
rootPath: posix.join(
|
||||
remoteRunnerFilesystemRoot,
|
||||
"context",
|
||||
remoteContextRoot!,
|
||||
"instructions",
|
||||
),
|
||||
},
|
||||
@@ -12047,8 +12055,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
bundle: {
|
||||
...skill.bundle,
|
||||
rootPath: posix.join(
|
||||
remoteRunnerFilesystemRoot,
|
||||
"context",
|
||||
remoteContextRoot!,
|
||||
"skills",
|
||||
`${index}-${skill.bundle.digest.slice(0, 12)}`,
|
||||
),
|
||||
|
||||
@@ -108,6 +108,9 @@ export async function prepareComputerProviderPackCache(input: {
|
||||
cacheRoot: string;
|
||||
sessionKey: string;
|
||||
owner: { ownerId: string; generation: number };
|
||||
// Must be the computer processRunner: it fences retired owners and places
|
||||
// every command in their owner slice, including commands using bypassSession.
|
||||
// Cross-owner receipt replacement relies on that admission/retirement fence.
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
verify: (root: string) => Promise<void>;
|
||||
stage: (root: string, runner: CommandManagedRuntimeRunner) => Promise<void>;
|
||||
|
||||
Reference in new issue
Block a user