mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix(native-runtime): stop failover backups from carrying throwaway provider state (#17618)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runner sessions on managed sandboxes keep a failover backup
of provider state, so a replacement sandbox can resume the session
> - Each backup copies the provider's runtime directory, with a
per-directory sha256 manifest, minus `excludeEntries`
> - Codex already excludes credentials and re-downloadable plugin
caches; OpenCode excluded nothing, and ACPX excluded only per-agent
credentials
> - OpenCode also leaked a temporary HOME on every launch, because
removing it failed on the read-only skill snapshot inside and the error
was swallowed
> - So every backup carried every earlier launch's HOME (with its npm
cache) plus re-installable plugin `node_modules`
> - This pull request removes those homes correctly, sweeps homes left
by earlier versions, and excludes the throwaway entries from OpenCode
and ACPX backups
> - The benefit is much smaller backups and live session directories,
while session history is still backed up and restored
## Linked Issues or Issue Description
Bug report described in-PR (bug report template).
**What happened?**
On one instance, current native failover backups totalled 24 GB across
about 300 sessions. For OpenCode sessions:
- `opencode/<session>/home-XXXXXX/`: 170 per-launch HOME directories
across 67 sessions, 9.3 GB. Each held `.npm` (about 111 MB of npm
cache), `.claude` (the skill snapshot) and `.local`.
- `opencode/<session>/config/opencode/node_modules`: 68 sessions, 4.3 GB
of OpenCode plugin installs.
One ACPX session also carried `acpx/<session>/data/pnpm/store` (229 MB
in the current backup, several GB in older copies).
Cause of the leaked homes: `startRuntime` in the OpenCode driver creates
a fresh `mkdtemp` HOME for each launch and seals the assigned skills
inside it (directories `0555`, files `0444`). `close()` and the
startup-failure paths removed it with `rm(..., { recursive: true, force:
true }).catch(() => undefined)`. That fails with `EACCES` on the sealed
directories, and the failure was ignored, so each launch left its HOME
behind.
**Expected behavior**
Per-launch homes are removed when the runtime closes, and backups hold
only state needed to resume a session. Caches that the provider
re-creates are left out.
**Steps to reproduce**
1. Run an OpenCode native session with at least one assigned skill on a
managed sandbox, over several turns or runs.
2. Inspect `<session>/` in the OpenCode runtime directory: one
`home-XXXXXX` per launch remains.
3. Inspect `failover-backups/current/opencode/<session>/`: the same
homes and `config/opencode/node_modules` are in the backup.
**Paperclip version or commit:** `master` at e335265568.
**Deployment mode:** any with native OpenCode/ACPX sessions on managed
sandboxes.
**Agent adapter(s) involved:** OpenCode (native), ACPX.
## What Changed
- OpenCode driver
(`packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts`):
- `removeIsolatedHome` unseals the skill snapshot with the existing
`releaseMaterializedNativeRuntimeSkills`, then removes the HOME. Close
and every startup-failure path use it. It only unseals when `.claude` is
a real directory, not a symlink.
- Per-launch homes are now created under `<session>/homes/home-XXXXXX`,
so one exact path can be excluded.
- When a session opens, `home-*` directories left at the session root by
earlier versions are removed. History is not in them: OpenCode keeps it
in `XDG_DATA_HOME` (`<session>/data/opencode/opencode.db`), and HOME
only holds caches, state locks and the skill copy.
- Exports `opencodeRuntimeSessionDirectoryName` (the existing
session-directory naming) and `OPENCODE_ISOLATED_HOMES_DIRECTORY` for
the server.
- `resolveNativeHarnessPersistenceProfile`
(`server/src/services/native-runtime/native-session-executor.ts`):
- OpenCode excludes `<session>/homes` and
`<session>/config/opencode/node_modules`.
- ACPX (every agent) excludes `acpx/<session>/home/.npm` and
`acpx/<session>/data/pnpm/store`, in addition to the existing per-agent
entries.
- `excludeEntries` are matched as exact relative paths (wildcards are
rejected), so the session directory is computed in the same way as the
existing ACPX entries.
- Exposes the two new exports through the server's runner vendor shim.
No code comments were added.
## Verification
- `pnpm exec vitest run src/drivers/opencode
src/drivers/runtime-context-materializer` (in
`packages/paperclip-runner/`): 108 passed. The 1 failure, `uses provider
structure rather than prose length to select a post-tool answer`, also
fails on unmodified `master`.
- The existing skill-tree test, with and without a startup retry, now
asserts that homes are created under `homes/` and that `homes/` is empty
after close.
- New test: a sealed legacy `home-*` at the session root is removed when
the session reopens, and `data/opencode/opencode.db` is kept.
- `pnpm exec vitest run src/services/native-runtime/
src/__tests__/native-harness-backup-expiration.test.ts` (in `server/`):
5080 passed. `plugin-runner-probe` could not run locally because of a
cargo manifest-path problem in this environment; it is unrelated to this
change.
- Profile tests cover the new OpenCode and ACPX exclusions, including a
session id that needs sanitizing.
- New checkpoint/restore round-trip tests for OpenCode and ACPX. They
use `syncRemoteRunnerDirectoryOut` for the backup and
`stageRemoteRunnerDirectory` for the replacement. Excluded caches are
absent from both the backup and the replacement, while history, config
and workspace files are restored and the live directory is unchanged.
- **Mutation checks**, each failing its test:
- removing the skill unsealing (homes leak again; 3 runner tests fail);
- removing the legacy sweep (1 test fails);
- emptying the OpenCode/ACPX exclusion lists (4 server tests fail).
- Checked by hand against the bundled OpenCode 1.18.34 binary, with the
driver's environment:
- launching with a fresh HOME and no `config/opencode/node_modules`
still lists earlier sessions;
- OpenCode reinstalls the plugin dependencies on launch.
- `pnpm run typecheck` in `server/` and `packages/paperclip-runner/`:
pass.
- Not run: server build and boot smoke.
## Risks
- **Rollout:** existing backups still contain legacy `home-*`
directories. After a restore, the sweep removes them when the session
opens, and the next backup no longer carries them.
- **First launch after restore:** OpenCode reinstalls its plugin
dependencies and npm repopulates its cache. This needs network access to
the npm registry, the same as a brand-new session.
- **Crash leftovers:** a HOME left behind by a hard crash (the runner
killed before `close()`) stays under `homes/` until the session
directory is removed. It is still excluded from backups. Homes under
`homes/` are not swept on open, to avoid touching a HOME that a
concurrently running runtime might still use.
- **pnpm store:** dropping the pnpm store from ACPX backups means the
next `pnpm install` in a replacement sandbox downloads packages again.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file edits, tests, and running the real OpenCode
binary to check what state it reads on resume).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7ebf98eafd
commit
34409a92c4
5 files changed
+247
-25
No files matched your search
@@ -1513,7 +1513,7 @@ describe("OpenCodeServerDriver", () => {
|
||||
);
|
||||
const isolatedHome = runtimeEnvironment.home;
|
||||
expect(startupFailurePending).toBe(false);
|
||||
expect(dirname(isolatedHome)).toBe(sessionRoot);
|
||||
expect(dirname(isolatedHome)).toBe(join(sessionRoot, "homes"));
|
||||
expect(basename(isolatedHome)).toMatch(/^home-/);
|
||||
await expect(
|
||||
readFile(
|
||||
@@ -1553,6 +1553,59 @@ describe("OpenCodeServerDriver", () => {
|
||||
readFile(join(instructionRoot, "sibling.md"), "utf8"),
|
||||
).resolves.toBe("instruction sibling\n");
|
||||
await session.close({ reason: "test" });
|
||||
await expect(readdir(join(sessionRoot, "homes"))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("removes isolated homes left by earlier launches when a session reopens", async () => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-legacy-home-"));
|
||||
const workspace = await mkdtemp(
|
||||
join(tmpdir(), "paperclip-opencode-legacy-home-workspace-"),
|
||||
);
|
||||
const skillRoot = join(root, "skill-source");
|
||||
const instructionRoot = join(root, "instruction-source");
|
||||
roots.push(root, workspace);
|
||||
await Promise.all([
|
||||
mkdir(skillRoot, { recursive: true }),
|
||||
mkdir(instructionRoot, { recursive: true }),
|
||||
]);
|
||||
await writeFile(join(skillRoot, "SKILL.md"), "# Assigned\n");
|
||||
await writeFile(join(instructionRoot, "AGENTS.md"), "instructions\n");
|
||||
const sessionRoot = join(root, "legacy");
|
||||
const legacyHome = join(sessionRoot, "home-AbC123");
|
||||
const sealedSkill = join(legacyHome, ".claude", "skills", "assigned");
|
||||
await mkdir(sealedSkill, { recursive: true });
|
||||
await mkdir(join(legacyHome, ".npm", "_cacache"), { recursive: true });
|
||||
await writeFile(join(legacyHome, ".npm", "_cacache", "entry"), "cache");
|
||||
await writeFile(join(sealedSkill, "SKILL.md"), "# Assigned\n");
|
||||
await chmod(join(sealedSkill, "SKILL.md"), 0o444);
|
||||
await chmod(sealedSkill, 0o555);
|
||||
await mkdir(join(sessionRoot, "data", "opencode"), { recursive: true });
|
||||
await writeFile(join(sessionRoot, "data", "opencode", "opencode.db"), "history");
|
||||
const driver = new OpenCodeServerDriver({
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
runtimeDirectory: root,
|
||||
command: fixture,
|
||||
runtimeContext: runtimeContext(skillRoot, instructionRoot),
|
||||
environment: {
|
||||
PATH: process.env.PATH,
|
||||
OPENROUTER_API_KEY: "fixture-key",
|
||||
},
|
||||
});
|
||||
const session = await driver.openSession({
|
||||
runId: "run-legacy-home",
|
||||
normalizedSessionId: "legacy",
|
||||
workingDirectory: workspace,
|
||||
});
|
||||
await expect(lstat(legacyHome)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await session.close({ reason: "test" });
|
||||
expect(
|
||||
(await readdir(sessionRoot)).filter((entry) => entry.startsWith("home-")),
|
||||
).toEqual([]);
|
||||
await expect(readdir(join(sessionRoot, "homes"))).resolves.toEqual([]);
|
||||
await expect(
|
||||
readFile(join(sessionRoot, "data", "opencode", "opencode.db"), "utf8"),
|
||||
).resolves.toBe("history");
|
||||
});
|
||||
|
||||
it("resends system instructions without repeating the task envelope on recovery", async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { accessSync, constants, readFileSync, realpathSync, statSync } from "nod
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
@@ -85,7 +86,10 @@ import {
|
||||
type OpenCodeMcpBridge,
|
||||
} from "./mcp-bridge.js";
|
||||
import { nativeMcpLaunchBinding } from "../native-mcp.js";
|
||||
import { materializeNativeRuntimeSkills } from "../runtime-context-materializer.js";
|
||||
import {
|
||||
materializeNativeRuntimeSkills,
|
||||
releaseMaterializedNativeRuntimeSkills,
|
||||
} from "../runtime-context-materializer.js";
|
||||
|
||||
export const OPENCODE_SERVER_DRIVER_KIND = "opencode_server" as const;
|
||||
export const QUALIFIED_OPENCODE_VERSION = "1.18.34" as const;
|
||||
@@ -369,6 +373,7 @@ export class OpenCodeServerDriver implements HarnessDriver {
|
||||
);
|
||||
await mkdir(root, { recursive: true, mode: 0o700 });
|
||||
await writeFile(join(root, "workspace"), `${cwd}\n`, { mode: 0o600 });
|
||||
await removeLegacyIsolatedHomes(root);
|
||||
const trace = await createProviderTraceFileSink({
|
||||
path: this.#options.environment?.PAPERCLIP_PROVIDER_TRACE_PATH,
|
||||
provider: "opencode",
|
||||
@@ -2281,7 +2286,9 @@ async function startRuntime(input: {
|
||||
// HOME contains a read-only skill snapshot, whose destination must be fresh.
|
||||
// Keep OpenCode's XDG data stable for provider-session recovery while giving
|
||||
// every launch (including retries and recovery) a new isolated HOME.
|
||||
const isolatedHome = await mkdtemp(join(input.root, "home-"));
|
||||
const homesRoot = join(input.root, OPENCODE_ISOLATED_HOMES_DIRECTORY);
|
||||
await mkdir(homesRoot, { recursive: true, mode: 0o700 });
|
||||
const isolatedHome = await mkdtemp(join(homesRoot, "home-"));
|
||||
await chmod(isolatedHome, 0o700);
|
||||
try {
|
||||
await materializeNativeRuntimeSkills(
|
||||
@@ -2289,18 +2296,14 @@ async function startRuntime(input: {
|
||||
join(isolatedHome, ".claude", "skills"),
|
||||
);
|
||||
} catch (error) {
|
||||
await rm(isolatedHome, { recursive: true, force: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await removeIsolatedHome(isolatedHome).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: input.options.dynamicTools,
|
||||
handler: input.dispatch,
|
||||
}).catch(async (error) => {
|
||||
await rm(isolatedHome, { recursive: true, force: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await removeIsolatedHome(isolatedHome).catch(() => undefined);
|
||||
throw error;
|
||||
});
|
||||
const assignedMcp = nativeMcpLaunchBinding(
|
||||
@@ -2335,7 +2338,7 @@ async function startRuntime(input: {
|
||||
const providerProxy = modelProvider === "paperclip" && input.options.environment?.PAPERCLIP_AI_PROVIDER_URL
|
||||
? await startOpenCodeProviderProxy(input.options.environment.PAPERCLIP_AI_PROVIDER_URL, input.options.environment.PAPERCLIP_AI_PROVIDER_KEY ?? "", providerModelId, input.options.environment).catch(async error => {
|
||||
await bridge.close().catch(() => {});
|
||||
await rm(isolatedHome, { recursive: true, force: true }).catch(() => {});
|
||||
await removeIsolatedHome(isolatedHome).catch(() => {});
|
||||
throw error;
|
||||
})
|
||||
: null;
|
||||
@@ -2568,9 +2571,7 @@ async function startRuntime(input: {
|
||||
await rm(join(configHome, "opencode", "opencode.json"), {
|
||||
force: true,
|
||||
}).catch(() => undefined);
|
||||
await rm(isolatedHome, { recursive: true, force: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await removeIsolatedHome(isolatedHome).catch(() => undefined);
|
||||
if (closeInput.finalizeTrace !== false) {
|
||||
await input.trace?.finish({ reason: closeInput.reason ?? null });
|
||||
}
|
||||
@@ -2585,9 +2586,7 @@ async function startRuntime(input: {
|
||||
await rm(join(configHome, "opencode", "opencode.json"), {
|
||||
force: true,
|
||||
}).catch(() => undefined);
|
||||
await rm(isolatedHome, { recursive: true, force: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await removeIsolatedHome(isolatedHome).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2962,8 +2961,9 @@ function sanitizedEnvironmentKeys(): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function sessionRoot(
|
||||
runtimeDirectory: string,
|
||||
export const OPENCODE_ISOLATED_HOMES_DIRECTORY = "homes";
|
||||
|
||||
export function opencodeRuntimeSessionDirectoryName(
|
||||
normalizedSessionId: string,
|
||||
): string {
|
||||
const safe = normalizedSessionId
|
||||
@@ -2971,7 +2971,35 @@ function sessionRoot(
|
||||
.slice(0, 120);
|
||||
if (!safe || safe === "." || safe === "..")
|
||||
throw new Error("Invalid normalized OpenCode session id");
|
||||
return join(resolve(runtimeDirectory), safe);
|
||||
return safe;
|
||||
}
|
||||
|
||||
function sessionRoot(
|
||||
runtimeDirectory: string,
|
||||
normalizedSessionId: string,
|
||||
): string {
|
||||
return join(
|
||||
resolve(runtimeDirectory),
|
||||
opencodeRuntimeSessionDirectoryName(normalizedSessionId),
|
||||
);
|
||||
}
|
||||
|
||||
async function removeIsolatedHome(home: string): Promise<void> {
|
||||
const skillsParent = await lstat(join(home, ".claude")).catch(() => null);
|
||||
if (skillsParent?.isDirectory()) {
|
||||
await releaseMaterializedNativeRuntimeSkills(
|
||||
join(home, ".claude", "skills"),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function removeLegacyIsolatedHomes(root: string): Promise<void> {
|
||||
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith("home-")) continue;
|
||||
await removeIsolatedHome(join(root, entry.name)).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkspace(value: string): string {
|
||||
|
||||
@@ -1880,13 +1880,35 @@ describe("native harness persistence profiles", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes only nested Codex launch state from ACPX recovery", () => {
|
||||
it("excludes per-launch OpenCode homes and re-installable plugins from OpenCode recovery", () => {
|
||||
const opencode = resolveNativeHarnessPersistenceProfile({
|
||||
provider: { kind: "opencode" },
|
||||
session: {
|
||||
driverKind: "opencode_server",
|
||||
normalizedSessionId: "company:agent/session 1",
|
||||
protocolVersion: 1,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
},
|
||||
} as unknown as NativeExecutionInputV1);
|
||||
expect(
|
||||
opencode.directories.find((directory) => directory.name === "opencode"),
|
||||
).toMatchObject({
|
||||
excludeEntries: [
|
||||
"company_agent_session_1/homes",
|
||||
"company_agent_session_1/config/opencode/node_modules",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes only nested Codex launch state and package caches from ACPX recovery", () => {
|
||||
const acpx = profile({ kind: "acpx", agent: "codex" }, "acpx_runtime");
|
||||
const sessionDirectory = acpxRuntimeSessionDirectoryName("session");
|
||||
expect(
|
||||
acpx.directories.find((directory) => directory.name === "acpx"),
|
||||
).toMatchObject({
|
||||
excludeEntries: [
|
||||
`acpx/${sessionDirectory}/home/.npm`,
|
||||
`acpx/${sessionDirectory}/data/pnpm/store`,
|
||||
`acpx/${sessionDirectory}/codex-home/tmp`,
|
||||
`acpx/${sessionDirectory}/codex-home/.tmp`,
|
||||
`acpx/${sessionDirectory}/codex-home/auth.json`,
|
||||
@@ -1907,7 +1929,12 @@ describe("native harness persistence profiles", () => {
|
||||
{ kind: "acpx", agent: "claude" },
|
||||
"acpx_runtime",
|
||||
).directories.find((directory) => directory.name === "acpx"),
|
||||
).toMatchObject({ excludeEntries: [] });
|
||||
).toMatchObject({
|
||||
excludeEntries: [
|
||||
`acpx/${sessionDirectory}/home/.npm`,
|
||||
`acpx/${sessionDirectory}/data/pnpm/store`,
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2623,6 +2650,100 @@ describe("remote provider checkpoint snapshots", () => {
|
||||
expect(await readFile(join(home, "logs", "unified.jsonl"), "utf8")).toBe("AUTH_SENTINEL");
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it.each<{
|
||||
name: string;
|
||||
provider: Record<string, unknown>;
|
||||
driverKind: string;
|
||||
directory: string;
|
||||
sessionPath: string;
|
||||
kept: Record<string, string>;
|
||||
omitted: Record<string, string>;
|
||||
}>([
|
||||
{
|
||||
name: "OpenCode",
|
||||
provider: { kind: "opencode" },
|
||||
driverKind: "opencode_server",
|
||||
directory: "opencode",
|
||||
sessionPath: "company_agent_session_1",
|
||||
kept: {
|
||||
"workspace": "/workspace\n",
|
||||
"data/opencode/opencode.db": "history",
|
||||
"config/opencode/opencode.json": "{}",
|
||||
"config/opencode/package.json": "{}",
|
||||
"home-legacy/.local/state/opencode/locks/lock": "legacy",
|
||||
},
|
||||
omitted: {
|
||||
"homes/home-AbC123/.npm/_cacache/index": "npm-cache",
|
||||
"homes/home-AbC123/.claude/skills/assigned/SKILL.md": "skill",
|
||||
"config/opencode/node_modules/@opencode-ai/plugin/package.json": "plugin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ACPX",
|
||||
provider: { kind: "acpx", agent: "pi" },
|
||||
driverKind: "acpx_runtime",
|
||||
directory: "acpx",
|
||||
sessionPath: `acpx/${acpxRuntimeSessionDirectoryName("company:agent/session 1")}`,
|
||||
kept: {
|
||||
"workspace": "/workspace\n",
|
||||
"pi-home/sessions/session.jsonl": "history",
|
||||
"data/pnpm/config.json": "{}",
|
||||
"home/.profile": "profile",
|
||||
},
|
||||
omitted: {
|
||||
"home/.npm/_cacache/index": "npm-cache",
|
||||
"data/pnpm/store/v10/files/00/blob": "pnpm-store",
|
||||
},
|
||||
},
|
||||
])("omits disposable $name package caches from the checkpoint and restores the session without them", async ({
|
||||
provider, driverKind, directory, sessionPath, kept, omitted,
|
||||
}) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "provider-cache-checkpoint-"));
|
||||
try {
|
||||
const live = join(root, "live", directory);
|
||||
const backup = join(root, "backup", directory);
|
||||
const replacement = join(root, "replacement", directory);
|
||||
const files: Record<string, string> = { ...kept, ...omitted };
|
||||
for (const [path, contents] of Object.entries(files)) {
|
||||
await mkdir(join(live, sessionPath, path, ".."), { recursive: true });
|
||||
await writeFile(join(live, sessionPath, path), contents);
|
||||
}
|
||||
const persistence = resolveNativeHarnessPersistenceProfile({
|
||||
provider,
|
||||
session: { normalizedSessionId: "company:agent/session 1", driverKind },
|
||||
} as unknown as NativeExecutionInputV1);
|
||||
const excludeEntries = persistence.directories.find(entry => entry.name === directory)!.excludeEntries;
|
||||
const execute = async ({ command, args }: { command: string; args: string[] }) => ({
|
||||
exitCode: 0, timedOut: false, stdout: execFileSync(command, args, { encoding: "utf8" }), stderr: "",
|
||||
});
|
||||
const copy = async (operations: Array<{ files: Array<{ sourcePath: string; targetPath: string }> }>) => {
|
||||
for (const operation of operations) for (const file of operation.files)
|
||||
await cp(file.sourcePath, file.targetPath, { recursive: true });
|
||||
};
|
||||
await syncRemoteRunnerDirectoryOut({ runner: { execute, syncOut: copy } as never,
|
||||
sourcePath: live, targetPath: backup, mode: 0o700, excludeEntries });
|
||||
for (const [path, contents] of Object.entries(kept))
|
||||
expect(await readFile(join(backup, sessionPath, path), "utf8")).toBe(contents);
|
||||
for (const path of Object.keys(omitted))
|
||||
await expect(access(join(backup, sessionPath, path))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
for (const [path, contents] of Object.entries(omitted))
|
||||
expect(await readFile(join(live, sessionPath, path), "utf8")).toBe(contents);
|
||||
|
||||
await stageRemoteRunnerDirectory({
|
||||
target: { kind: "remote", transport: "provider", remoteCwd: root, runner: { syncIn: copy } } as never,
|
||||
runner: { syncIn: copy } as never,
|
||||
sourcePath: backup,
|
||||
targetPath: replacement,
|
||||
mode: 0o700,
|
||||
excludeEntries,
|
||||
});
|
||||
for (const [path, contents] of Object.entries(kept))
|
||||
expect(await readFile(join(replacement, sessionPath, path), "utf8")).toBe(contents);
|
||||
for (const path of Object.keys(omitted))
|
||||
await expect(access(join(replacement, sessionPath, path))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("keeps Codex session state while omitting re-downloadable remote plugins from the checkpoint", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "codex-plugin-checkpoint-"));
|
||||
try {
|
||||
|
||||
@@ -109,6 +109,8 @@ import {
|
||||
completeRetainedNativeSessionCleanup,
|
||||
completeTerminatedLocalNativeSessionCleanup,
|
||||
acpxRuntimeSessionDirectoryName,
|
||||
OPENCODE_ISOLATED_HOMES_DIRECTORY,
|
||||
opencodeRuntimeSessionDirectoryName,
|
||||
createNativeSessionBackend,
|
||||
createRunnerdCodexTransport,
|
||||
executeNativeSession,
|
||||
@@ -410,6 +412,14 @@ const CODEX_HOME_CHECKPOINT_EXCLUDED_ENTRIES = [
|
||||
...CODEX_HOME_NON_PERSISTENT_ENTRIES,
|
||||
...CODEX_HOME_REDOWNLOADABLE_ENTRIES,
|
||||
] as const;
|
||||
const OPENCODE_RUNTIME_CHECKPOINT_EXCLUDED_ENTRIES = [
|
||||
OPENCODE_ISOLATED_HOMES_DIRECTORY,
|
||||
"config/opencode/node_modules",
|
||||
] as const;
|
||||
const ACPX_RUNTIME_CHECKPOINT_EXCLUDED_ENTRIES = [
|
||||
"home/.npm",
|
||||
"data/pnpm/store",
|
||||
] as const;
|
||||
const RUNNERD_CONTROL_PLANE_STATE_SCHEMA =
|
||||
"paperclip.runner.durable.control-plane-state.v1";
|
||||
const RUNNERD_STATE_SCHEMA = "paperclip.runner.durable.state.v1";
|
||||
@@ -5646,7 +5656,10 @@ export function resolveNativeHarnessPersistenceProfile(
|
||||
? {
|
||||
name: "opencode",
|
||||
location: "filesystem",
|
||||
excludeEntries: [],
|
||||
excludeEntries: OPENCODE_RUNTIME_CHECKPOINT_EXCLUDED_ENTRIES.map(
|
||||
(entry) =>
|
||||
`${opencodeRuntimeSessionDirectoryName(nativeSessionKey(execution))}/${entry}`,
|
||||
),
|
||||
}
|
||||
: execution.provider.kind === "acpx"
|
||||
? {
|
||||
@@ -5658,8 +5671,10 @@ export function resolveNativeHarnessPersistenceProfile(
|
||||
// nor necessary to restore. Credentials and launch-time config
|
||||
// are also re-materialized in the replacement sandbox. Grok
|
||||
// diagnostics can contain auth fields and are not session state.
|
||||
excludeEntries:
|
||||
execution.provider.agent === "grok"
|
||||
excludeEntries: [
|
||||
...ACPX_RUNTIME_CHECKPOINT_EXCLUDED_ENTRIES.map((entry) =>
|
||||
`acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/${entry}`),
|
||||
...(execution.provider.agent === "grok"
|
||||
? ["auth.json", "auth-refresh.json", "auth-refresh.json.tmp", "config.toml", "logs"].map((entry) =>
|
||||
`acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/grok-home/${entry}`)
|
||||
: execution.provider.agent === "codex"
|
||||
@@ -5667,7 +5682,8 @@ export function resolveNativeHarnessPersistenceProfile(
|
||||
(entry) =>
|
||||
`acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/codex-home/${entry}`,
|
||||
)
|
||||
: [],
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
|
||||
+4
@@ -107,6 +107,10 @@ export const PAPERCLIP_EXECUTION_PROMPT_REVISION =
|
||||
runner.PAPERCLIP_EXECUTION_PROMPT_REVISION;
|
||||
export const acpxRuntimeSessionDirectoryName =
|
||||
runner.acpxRuntimeSessionDirectoryName;
|
||||
export const OPENCODE_ISOLATED_HOMES_DIRECTORY =
|
||||
runner.OPENCODE_ISOLATED_HOMES_DIRECTORY;
|
||||
export const opencodeRuntimeSessionDirectoryName =
|
||||
runner.opencodeRuntimeSessionDirectoryName;
|
||||
export const canonicalNativeRuntimeContextDigest =
|
||||
runner.canonicalNativeRuntimeContextDigest;
|
||||
export const createNativeSessionBackend = runner.createNativeSessionBackend;
|
||||
|
||||
Reference in new issue
Block a user