mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
fix(boat): stage immutable context per bootstrap attempt
Bind generated instructions and skills to fresh attempt-owned snapshot paths, retaining old immutable snapshots without touching persistent user files or provider HOME. Record possible process dispatch only at the actual launch RPC boundary so preparation failures can retire their exact owner safely. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7f646066e8
commit
469da8b2c2
2 files changed
+88
-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)}`,
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user