feat(acpx): stage once per remote ACP session - compatible resume reuses staged runtime, no cross-session credential reuse (#10089)

## Thinking Path

> - Paperclip coordinates autonomous agent work across isolated
company-scoped sessions
> - The ACP remote lane stages workspaces and managed home state inside
the sandbox so sessions can resume safely
> - If a compatible resume re-staged everything every time, it would
waste work and risk inconsistent session reuse behavior
> - If an incompatible resume reused the wrong staged runtime, it could
cross session boundaries or leak credentials
> - This pull request keeps the session fingerprint as the scoping key
and adds a staged-runtime cache keyed to that fingerprint
> - Compatible resumes now reuse the already staged runtime while
incompatible fingerprints stage fresh
> - The benefit is faster safe resumes without weakening session
isolation or credential separation

## Linked Issues or Issue Description

### Problem or motivation
The ACP remote lane currently needs to preserve safe resume behavior
without repeatedly re-staging work that is already valid for the same
session. The failure mode to avoid is letting one session reuse another
session's staged workspace or credentials.

### Proposed solution
Keep the session fingerprint as the scoping key and add a staged-runtime
cache keyed to that fingerprint. When the fingerprint matches, reuse the
already staged workspace and managed home. When the fingerprint changes,
stage fresh.

### Alternatives considered
- Always restage on resume: safest mechanically, but wastes work and
breaks the compatible-resume optimization.
- Reuse without fingerprint scoping: too risky because it could cross
session boundaries.

### Roadmap alignment
This is a narrow implementation change for the ACP remote lane and does
not duplicate any broader roadmap item I could find in `ROADMAP.md`.

### Additional context
The change preserves the existing session fingerprint contents and codex
auth copy-back cadence while adding tests for compatible reuse,
incompatible fresh staging, no cross-session credential reuse, and
failed-turn eviction.

## What Changed

- Added a staged-runtime cache in the ACP remote execution path keyed by
the session fingerprint.
- Reused the existing staged workspace and managed home for compatible
resumes.
- Kept incompatible fingerprints on the fresh staging path.
- Preserved the existing session fingerprint contents and codex auth
copy-back cadence.
- Added tests for compatible reuse, incompatible fresh staging, no
cross-session credential reuse, and failed-turn eviction.

## Verification

- `pnpm exec vitest run
packages/adapter-utils/src/acpx-engine/execute.test.ts` (74/74 pass,
including the active-turn lease regression)
- `pnpm exec tsc -p packages/adapter-utils/tsconfig.json --noEmit`
- Verified the PR touches only
`packages/adapter-utils/src/acpx-engine/execute.ts` and
`packages/adapter-utils/src/acpx-engine/execute.test.ts`

## Risks

- A cache eviction bug could cause an unavailable or partially staged
runtime to be reused.
- If the fingerprint scoping regressed, a session could incorrectly
reuse another session's state.
- The change is isolated to the ACP remote lane, but it still affects
resume behavior for that path.

## Model Used

OpenAI Codex, GPT-5, reasoning-capable coding agent, tool-enabled
session.

## 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
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-23 10:13:44 -07:00 committed by GitHub
parent a7186dce4b
commit d36ea13e08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1287 additions and 118 deletions

View File

@ -1981,3 +1981,619 @@ describe("ACPX engine remote managed-home seam (PR 2: per-adapter home seed)", (
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
});
});
describe("ACPX engine remote session-lifecycle re-staging (PR 3: stage once / reuse on compatible resume)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
async function setupRemoteSandbox() {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
const executionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
};
return { root, stateDir, localCwd, remoteCwd, executionTarget };
}
// A runtime double that records ensureSession inputs and can be told to make
// the turn fail (to exercise the teardown/eviction path).
function recordingRuntime(input: {
ensureInputs: Array<Record<string, unknown>>;
terminalStatus?: "completed" | "failed";
}) {
return {
ensureSession: async (session: Record<string, unknown>) => {
input.ensureInputs.push(session);
return {
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
};
},
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result:
input.terminalStatus === "failed"
? Promise.resolve({ status: "failed", error: new Error("boom") })
: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
setConfigOption: async () => {},
close: async () => {},
};
}
function baseExecuteArgs(input: {
stateDir: string;
localCwd: string;
executionTarget: Record<string, unknown>;
env?: Record<string, string>;
}) {
return {
agent: { id: "agent-1", companyId: "company-1" },
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir: input.stateDir,
cwd: input.localCwd,
mode: "persistent",
warmHandleIdleMs: 60_000,
...(input.env ? { env: input.env } : {}),
},
context: {},
authToken: "real-run-jwt",
executionTarget: input.executionTarget,
onLog: async () => {},
onMeta: async () => {},
};
}
it("test_acp_resume_compatible_session_does_not_restage", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
});
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
const second = await execute({
runId: "run-b",
runtime: { sessionParams: first.sessionParams },
...base,
} as never);
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
// Staging (workspace ship + home seed) ran exactly ONCE across both runs:
// the compatible resume reused the already-staged in-sandbox runtime.
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1);
// Both runs bind session/new (and resume) to the in-sandbox workspace cwd...
expect(ensureInputs[0]?.cwd).toBe(remoteCwd);
expect(ensureInputs[1]?.cwd).toBe(remoteCwd);
// ...and the second run RESUMES the first session rather than starting fresh.
expect(ensureInputs[1]?.resumeSessionId).toBe(first.sessionId);
});
it("test_acp_resume_incompatible_fingerprint_stages_fresh", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
});
const first = await execute({
runId: "run-a",
runtime: {},
...baseExecuteArgs({ stateDir, localCwd, executionTarget, env: { FOO: "a" } }),
} as never);
// A changed adapter env value shifts the session fingerprint → a different
// sessionKey → the cache slot does not match, so staging runs fresh.
const second = await execute({
runId: "run-b",
runtime: { sessionParams: first.sessionParams },
...baseExecuteArgs({ stateDir, localCwd, executionTarget, env: { FOO: "b" } }),
} as never);
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
// Incompatible fingerprint → staged fresh, no stale reuse.
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
expect(ensureInputs[0]?.cwd).toBe(remoteCwd);
expect(ensureInputs[1]?.cwd).toBe(remoteCwd);
// The second run does NOT resume the first session (fingerprint differs).
expect(ensureInputs[1]?.resumeSessionId).toBeUndefined();
});
it("test_warm_handle_scoped_per_fingerprint_no_cross_session_credential_reuse", async () => {
const { root, stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
// Two managed homes, one per session, each carrying a distinct credential
// marker. The seam seeds whichever home belongs to the current run.
const homeA = path.join(root, "home-a");
const homeB = path.join(root, "home-b");
await fs.mkdir(homeA, { recursive: true });
await fs.mkdir(homeB, { recursive: true });
await fs.writeFile(path.join(homeA, "auth.json"), JSON.stringify({ token: "SECRET-A" }), "utf8");
await fs.writeFile(path.join(homeB, "auth.json"), JSON.stringify({ token: "SECRET-B" }), "utf8");
const seededHomeEnv: string[] = [];
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
prepareRemoteManagedHome: async (input) => {
const localHome = input.env.SESSION_MARKER === "b" ? homeB : homeA;
const stagedRuntime = await input.stage([
{ key: "home", localDir: localHome, followSymlinks: true },
]);
input.env.MANAGED_HOME = stagedRuntime.assetDirs.home ?? "";
seededHomeEnv.push(input.env.MANAGED_HOME);
return { stagedRuntime };
},
});
const first = await execute({
runId: "run-a",
runtime: {},
...baseExecuteArgs({ stateDir, localCwd, executionTarget, env: { SESSION_MARKER: "a" } }),
} as never);
// Different fingerprint (SESSION_MARKER changed) → different sessionKey. If the
// cache were NOT fingerprint-scoped, this run could silently inherit session A's
// staged auth.json without re-seeding. It must instead seed its own home.
const second = await execute({
runId: "run-b",
runtime: { sessionParams: first.sessionParams },
...baseExecuteArgs({ stateDir, localCwd, executionTarget, env: { SESSION_MARKER: "b" } }),
} as never);
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
// Each session staged its OWN managed home — no cross-session reuse.
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
expect(seededHomeEnv).toHaveLength(2);
// Session B's staged home holds session B's credential, never session A's.
const bHome = seededHomeEnv[1]!;
await expect(fs.readFile(path.join(bHome, "auth.json"), "utf8")).resolves.toContain("SECRET-B");
});
it("test_acp_failed_turn_evicts_staged_runtime_so_resume_restages", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
// The first turn fails; the second (compatible) run then completes.
createRuntime: (() => {
let call = 0;
return () => {
call += 1;
return recordingRuntime({
ensureInputs,
terminalStatus: call === 1 ? "failed" : "completed",
}) as never;
};
})(),
});
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
const second = await execute({
runId: "run-b",
runtime: { sessionParams: first.sessionParams },
...base,
} as never);
expect(first.exitCode).toBe(1);
expect(second.exitCode).toBe(0);
// A failed turn discards the staged runtime, so the next run stages fresh
// instead of reusing a torn-down session's staged credentials.
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
});
// Greptile P1 "Cache Reuse Bypasses Session Compatibility": a fresh invocation
// that shares company/agent/task/fingerprint (hence sessionKey) with a prior
// run but carries NO sessionParams starts a new ACP session — it must NOT
// inherit the prior session's staged workspace + managed home.
it("test_acp_reuse_requires_compatible_resume_not_just_session_key", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
let seamCalls = 0;
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
prepareRemoteManagedHome: async (input) => {
seamCalls += 1;
return { stagedRuntime: await input.stage([]) };
},
});
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
// Same config (identical sessionKey) but sessionParams cleared → this is a
// NEW session, not a resume of A. The old code reused A's staged runtime on a
// bare sessionKey hit; the compatibility gate now forces a fresh stage.
const second = await execute({ runId: "run-b", runtime: {}, ...base } as never);
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
// Staged (and re-seeded the managed home) fresh for the new session — no
// silent inheritance of the prior session's staged credentials.
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
expect(seamCalls).toBe(2);
// B binds a fresh session/new (no resumeSessionId), it does not resume A.
expect(ensureInputs[1]?.cwd).toBe(remoteCwd);
expect(ensureInputs[1]?.resumeSessionId).toBeUndefined();
});
// Greptile P1 "Teardown Invalidates Cached Runtime": the per-run copy-back must
// fire on every run (incl. a reused resume) while the one-time host staged-temp
// cleanup must NOT fire between clean runs — otherwise the reused staged runtime
// would be invalidated before the next resume.
it("test_reused_resume_copies_back_per_run_without_disposing_staged_temp", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
let teardownCalls = 0;
let disposeCalls = 0;
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
prepareRemoteManagedHome: async (input) => {
const stagedRuntime = await input.stage([]);
return {
stagedRuntime,
teardown: async () => {
teardownCalls += 1;
},
disposeStaged: async () => {
disposeCalls += 1;
},
};
},
});
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
const second = await execute({
runId: "run-b",
runtime: { sessionParams: first.sessionParams },
...base,
} as never);
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
// Staged once, reused on the compatible resume.
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1);
// Per-run copy-back fired on BOTH runs — cadence unchanged.
expect(teardownCalls).toBe(2);
// The staged temp was never disposed while the entry stayed warm for reuse,
// so the resume found its staged home intact.
expect(disposeCalls).toBe(0);
expect(ensureInputs[1]?.resumeSessionId).toBe(first.sessionId);
});
// The one-time dispose DOES fire when the staged runtime is actually dropped
// (here: a failed turn), releasing the host staged-temp — the copy-back also
// still fires on the failure path.
it("test_dropped_staged_runtime_disposes_host_temp", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
let teardownCalls = 0;
let disposeCalls = 0;
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: () => recordingRuntime({ ensureInputs, terminalStatus: "failed" }) as never,
prepareRemoteManagedHome: async (input) => ({
stagedRuntime: await input.stage([]),
teardown: async () => {
teardownCalls += 1;
},
disposeStaged: async () => {
disposeCalls += 1;
},
}),
});
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
const result = await execute({ runId: "run-a", runtime: {}, ...base } as never);
expect(result.exitCode).toBe(1);
// Failed turn → staged runtime dropped → host staged-temp disposed once, and
// the per-run copy-back still fired.
expect(teardownCalls).toBe(1);
expect(disposeCalls).toBe(1);
});
it("test_idle_staged_runtime_cleanup_waits_for_active_turn_release", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const events: string[] = [];
let currentNow = 0;
let releaseTurn!: () => void;
let signalTurnStarted!: () => void;
const turnStarted = new Promise<void>((resolve) => {
signalTurnStarted = resolve;
});
const turnCompleted = new Promise<void>((resolve) => {
releaseTurn = resolve;
});
const execute = createAcpxEngineExecutor({
now: () => currentNow,
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: (() => {
let call = 0;
return () => {
call += 1;
return {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => {
if (call === 2) signalTurnStarted();
return {
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result:
call === 2
? turnCompleted.then(() => ({ status: "completed", stopReason: "end_turn" }))
: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
};
},
setConfigOption: async () => {},
close: async () => {},
} as never;
};
})(),
prepareRemoteManagedHome: async (input) => {
events.push(`stage:${input.runId}`);
return {
stagedRuntime: await input.stage([]),
disposeStaged: async () => {
events.push(`dispose:${input.runId}`);
},
};
},
});
const base = baseExecuteArgs({
stateDir,
localCwd,
executionTarget,
env: { SESSION_MARKER: "idle-eviction" },
});
const first = await execute({ runId: "run-a", runtime: {}, ...base } as never);
expect(first.exitCode).toBe(0);
const second = execute({
runId: "run-b",
runtime: { sessionParams: first.sessionParams },
...base,
} as never);
await turnStarted;
currentNow = 10_000;
const third = execute({ runId: "run-c", runtime: {}, ...base } as never);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["stage:run-a"]);
releaseTurn();
const [resultB, resultC] = await Promise.all([second, third]);
expect(resultB.exitCode).toBe(0);
expect(resultC.exitCode).toBe(0);
expect(events).toEqual(["stage:run-a", "dispose:run-a", "stage:run-c"]);
});
// Superseding an incompatible session that collides on sessionKey re-stages
// fresh AND releases the superseded entry's host staged-temp (no leak, no
// reuse of the old session's staged credentials).
it("test_incompatible_restage_disposes_superseded_staged_temp", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
const disposedRunIds: string[] = [];
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
prepareRemoteManagedHome: async (input) => ({
stagedRuntime: await input.stage([]),
disposeStaged: async () => {
disposedRunIds.push(input.runId);
},
}),
});
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
// Run A completes cleanly and caches its staged runtime.
await execute({ runId: "run-a", runtime: {}, ...base } as never);
// Run B: same sessionKey, no sessionParams → not a compatible resume. It must
// drop + dispose A's superseded staged entry, then stage fresh.
await execute({ runId: "run-b", runtime: {}, ...base } as never);
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(2);
// A's staged temp was disposed when B superseded it.
expect(disposedRunIds).toContain("run-a");
});
// Greptile P1 "Concurrent Runs Corrupt Cache Ownership": two overlapping runs
// of the same session key must not ship into the same remote workspace at once.
// The per-key staging lock serializes the stage-or-reuse section, so their
// staging windows never overlap.
it("test_concurrent_same_session_staging_is_serialized", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
const events: string[] = [];
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => recordingRuntime({ ensureInputs }) as never,
prepareRemoteManagedHome: async (input) => {
events.push(`enter:${input.runId}`);
// Yield to the event loop so an unserialized second run would interleave
// its own enter here before we finish staging.
await new Promise((resolve) => setTimeout(resolve, 5));
const stagedRuntime = await input.stage([]);
events.push(`exit:${input.runId}`);
return { stagedRuntime };
},
});
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
// Both runs share the sessionKey (identical config) and start concurrently.
const [a, b] = await Promise.all([
execute({ runId: "run-a", runtime: {}, ...base } as never),
execute({ runId: "run-b", runtime: {}, ...base } as never),
]);
expect(a.exitCode).toBe(0);
expect(b.exitCode).toBe(0);
// Each staging window is a matched enter/exit pair with no interleaving — the
// lock serialized them (never enter,enter,...,exit,exit).
expect(events).toHaveLength(4);
expect(events[0]).toMatch(/^enter:/);
expect(events[1]).toBe(`exit:${events[0]!.slice("enter:".length)}`);
expect(events[2]).toMatch(/^enter:/);
expect(events[3]).toBe(`exit:${events[2]!.slice("enter:".length)}`);
});
// Greptile P1 "Lock Ends Before Use": a same-session re-stage must wait for
// the prior run's active turn and cleanup to finish before it can touch the
// staged remote workspace again.
it("test_concurrent_same_session_staging_waits_for_active_turn_cleanup", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const events: string[] = [];
let releaseTurn!: () => void;
let signalTurnStarted!: () => void;
const turnStarted = new Promise<void>((resolve) => {
signalTurnStarted = resolve;
});
const turnCompleted = new Promise<void>((resolve) => {
releaseTurn = resolve;
});
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => ({
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => {
signalTurnStarted();
return {
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: turnCompleted.then(() => ({ status: "completed", stopReason: "end_turn" })),
cancel: async () => {},
};
},
setConfigOption: async () => {},
close: async () => {},
}) as never,
prepareRemoteManagedHome: async (input) => {
events.push(`enter:${input.runId}`);
await new Promise((resolve) => setTimeout(resolve, 5));
const stagedRuntime = await input.stage([]);
events.push(`exit:${input.runId}`);
return { stagedRuntime };
},
});
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
const runA = execute({ runId: "run-a", runtime: {}, ...base } as never);
await turnStarted;
const runB = execute({ runId: "run-b", runtime: {}, ...base } as never);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).not.toContain("enter:run-b");
releaseTurn();
await runA;
events.push("run-a-finished");
await runB;
expect(events).toContain("enter:run-b");
expect(events.indexOf("enter:run-b")).toBeGreaterThan(events.indexOf("run-a-finished"));
});
// The per-session lease must be released when a run is abandoned before it
// reaches the executor's cleanup (e.g. staging or a bridge fails to start),
// otherwise the next run of the same session waits on the lease forever. Here
// the first run's staging throws; the second run of the same session must
// still acquire the lease and stage instead of deadlocking.
it("test_failed_staging_releases_lease_so_next_same_session_run_proceeds", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
const events: string[] = [];
let failNextStaging = true;
const execute = createAcpxEngineExecutor({
warmHandles: new Map(),
stagedRuntimes: new Map(),
stagingLocks: new Map(),
createRuntime: () => ({
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
setConfigOption: async () => {},
close: async () => {},
}) as never,
prepareRemoteManagedHome: async (input) => {
events.push(`enter:${input.runId}`);
if (failNextStaging) {
failNextStaging = false;
throw new Error("staging boom");
}
const stagedRuntime = await input.stage([]);
events.push(`exit:${input.runId}`);
return { stagedRuntime };
},
});
const base = baseExecuteArgs({ stateDir, localCwd, executionTarget });
await expect(execute({ runId: "run-a", runtime: {}, ...base } as never)).rejects.toThrow(
"staging boom",
);
// If the failed run had stranded its lease, this second same-session run
// would hang on it and the test would time out.
const resultB = await execute({ runId: "run-b", runtime: {}, ...base } as never);
expect(resultB.exitCode).toBe(0);
expect(events).toContain("enter:run-b");
expect(events).toContain("exit:run-b");
});
});

View File

@ -129,6 +129,52 @@ export interface RuntimeCacheEntry {
cleanupTimer?: NodeJS.Timeout;
}
/**
* A remote runner-backed session's staged runtime, kept warm across runs so a
* compatible resume reuses it instead of re-shipping the workspace / re-seeding
* the managed home (PR 3: "stage once per session"). Keyed by the session's
* `sessionKey` (`paperclip:companyId:agentId:taskKey:fingerprint`) the SAME
* fingerprint scoping the warm handle uses so one session can never read
* another session's staged credentials: a different agent/task/config hashes to
* a different key, misses this cache, and stages its own home.
*
* Remote sessions are never held in the warm-handle cache (their agent process
* lives behind a per-run process-session bridge, torn down each run and resumed
* via `session/load`); the only thing that survives between their runs is the
* in-sandbox staged workspace + home, which this cache reuses.
*/
export interface StagedRuntimeCacheEntry {
stagedRuntime: PreparedAdapterExecutionTargetRuntime;
/**
* The env keys the per-adapter managed-home seam mutated when it staged (e.g.
* `CODEX_HOME` repointed onto the in-sandbox home). Re-applied verbatim on a
* reused run so the spawned agent still receives the in-sandbox home paths
* without re-invoking the seam. These values are deterministic (derived from
* the staged asset dirs), so they are identical across the session's runs.
*/
envDelta: Record<string, string>;
/**
* The seam's per-run copy-back (codex auth copy-back via `restoreWorkspace()`),
* or null for adapters/customs with no seam. Reused on every run's teardown so
* the copy-back cadence stays exactly per-run unchanged from PR 2.
* `restoreWorkspace()` reads the sandbox live through the stable (stateless)
* runner, so reusing the closure across resumes copies back the current
* in-sandbox credential, not a stale snapshot. It never removes the staged
* in-sandbox home, so re-running it on each reuse can't invalidate this entry.
*/
teardown: (() => Promise<void>) | null;
/**
* The seam's one-time host-side staged-resource cleanup (e.g. remove the
* staged home temp dir), or null. Fired ONLY when this entry is dropped
* failed/cancelled/timed-out turn, incompatible re-stage, or idle eviction
* never while the entry stays warm for reuse. Kept separate from `teardown`
* so a clean turn's per-run copy-back can't delete resources the next
* compatible resume still relies on.
*/
dispose: (() => Promise<void>) | null;
lastUsedAt: number;
}
interface AcpxEngineSettings {
adapterType: string;
moduleDir: string;
@ -198,20 +244,55 @@ export interface AcpxRemoteManagedHomeContext {
export interface AcpxRemoteManagedHomeResult {
stagedRuntime: PreparedAdapterExecutionTargetRuntime;
/**
* Invoked once on every teardown/exit path (mirrors the CLI restore-hook +
* staged-temp cleanup finally). For codex this runs `restoreWorkspace()` the
* seam that fires the auth copy-back and removes the staged home temp dir.
* Failures are logged by the seam, never fatal to the run result (an
* unclean-teardown copy-back miss is the accepted `refresh_token_reused`
* residual, loud on the next host Codex use, never silent).
* Per-run copy-back, invoked once on every teardown/exit path (mirrors the CLI
* restore-hook finally). For codex this runs `restoreWorkspace()` the seam
* that fires the auth copy-back. It reads the sandbox live and does NOT remove
* the staged in-sandbox home/workspace, so it is safe to re-run on every
* compatible resume that reuses the staged runtime the copy-back cadence
* stays exactly per-run. Failures are logged by the seam, never fatal to the
* run result (an unclean-teardown copy-back miss is the accepted
* `refresh_token_reused` residual, loud on the next host Codex use, never
* silent).
*
* Host-side staged-resource cleanup (e.g. removing the staged home temp dir)
* is NOT done here it moved to {@link disposeStaged} so that reusing the
* cached staged runtime across resumes never destroys resources a later run
* still needs.
*/
teardown?: () => Promise<void>;
/**
* One-time cleanup of host-side staged resources (e.g. the curated staged
* home temp dir). Split out from {@link teardown} so it fires ONLY when the
* staged runtime is actually dropped a failed/cancelled/timed-out turn, an
* incompatible re-stage, or idle eviction never on a clean turn that keeps
* the staged runtime warm for the next compatible resume. Idempotent (safe to
* call more than once it force-removes and swallows already-gone paths).
* Null for adapters that seed from a managed cache and hold no disposable
* temp.
*/
disposeStaged?: () => Promise<void>;
}
export interface AcpxEngineExecutorOptions {
createRuntime?: AcpxRuntimeFactory;
now?: () => number;
warmHandles?: Map<string, RuntimeCacheEntry>;
/**
* Per-session staged-runtime cache for the remote runner-backed lane (PR 3).
* Keyed by `sessionKey`. Reused across runs so a compatible resume does not
* re-ship the workspace / re-seed the managed home. Defaults to a shared
* module-level map; tests pass an isolated map.
*/
stagedRuntimes?: Map<string, StagedRuntimeCacheEntry>;
/**
* Per-`sessionKey` staging mutex for the remote runner-backed lane (PR 3).
* Serializes the stage-or-reuse decision so two overlapping runs of the same
* session can never ship into the same remote workspace concurrently (one
* stages while the other waits, then re-checks the cache). Defaults to a
* shared module-level map; tests pass an isolated map. Entries are ephemeral
* cleared as soon as the last waiter for a key finishes staging.
*/
stagingLocks?: Map<string, Promise<unknown>>;
adapterType?: string;
moduleDir?: string;
packageRootDir?: string;
@ -262,11 +343,28 @@ interface AcpxPreparedRuntime {
// are what PR 2 (managed-home seeding + codex copy-back) and PR 3 (session
// lifecycle re-staging) build on.
stagedRuntime: PreparedAdapterExecutionTargetRuntime | null;
// Teardown hook from the per-adapter remote managed-home seam: runs the
// codex auth copy-back (via `restoreWorkspace()`) and removes staged temp
// dirs. Invoked once on every exit path by `cleanupRemoteBridges`. Null for
// local runs, the runner-less fallback, and adapters with no seam.
// Per-run copy-back hook from the per-adapter remote managed-home seam: runs
// the codex auth copy-back (via `restoreWorkspace()`). Invoked once on every
// exit path by `cleanupRemoteBridges`; it never removes staged temp, so it is
// safe on every compatible resume. Null for local runs, the runner-less
// fallback, and adapters with no seam.
remoteManagedHomeTeardown: (() => Promise<void>) | null;
// One-time host-side staged-resource cleanup from the seam (remove staged temp
// dirs). Fired ONLY when the staged runtime is dropped (failed/cancelled/timed
// -out turn, incompatible re-stage, idle eviction), not on a clean turn that
// keeps the runtime warm. Null for local runs, the runner-less fallback, and
// adapters with no disposable temp.
remoteStagingDispose: (() => Promise<void>) | null;
// PR 3: for the remote runner-backed lane, the env keys the managed-home seam
// mutated on this run (or the reused delta on a compatible resume), so the
// executor can cache/refresh the staged-runtime entry after a clean turn.
// Null for local runs, the runner-less fallback, and non-remote lanes.
remoteStagingEnvDelta: Record<string, string> | null;
// Per-session staging lease held from the initial stage-or-reuse decision
// through the active turn and released only after bridge cleanup completes.
// This keeps later overlapping runs from re-staging into the same remote
// workspace while a prior turn is still using it.
sessionStagingLeaseRelease: (() => void) | null;
remoteExecutionIdentity: Record<string, unknown> | null;
skillPromptInstructions: string;
skillsIdentity: Record<string, unknown>;
@ -277,6 +375,8 @@ interface AcpxPreparedRuntime {
}
const defaultWarmHandles = new Map<string, RuntimeCacheEntry>();
const defaultStagedRuntimes = new Map<string, StagedRuntimeCacheEntry>();
const defaultStagingLocks = new Map<string, Promise<unknown>>();
function resolveEngineSettings(options: AcpxEngineExecutorOptions): AcpxEngineSettings {
const moduleDir = path.resolve(options.moduleDir ?? defaultModuleDir);
@ -1070,6 +1170,9 @@ async function stageAcpRemoteRuntime(input: {
target: AdapterExecutionTarget;
adapterKey: string;
workspaceLocalDir: string;
// Pin the in-sandbox workspace dir so it provably equals the deterministic
// `sessionCwd` the engine folded into the session fingerprint (PR 3).
workspaceRemoteDir?: string;
timeoutSec: number;
assets?: AdapterManagedRuntimeAsset[];
onLog: AdapterExecutionContext["onLog"];
@ -1085,6 +1188,7 @@ async function stageAcpRemoteRuntime(input: {
adapterKey: input.adapterKey,
timeoutSec: input.timeoutSec,
workspaceLocalDir: input.workspaceLocalDir,
...(input.workspaceRemoteDir ? { workspaceRemoteDir: input.workspaceRemoteDir } : {}),
...(input.assets && input.assets.length > 0 ? { assets: input.assets } : {}),
onProgress: (line) => input.onLog("stdout", line),
onRuntimeProgress: input.onRuntimeProgress,
@ -1334,108 +1438,25 @@ async function buildRuntime(input: {
executionTarget.transport === "sandbox" &&
Boolean(executionTarget.runner) &&
Boolean(agentCommandShell);
// Ship the workspace into the sandbox and capture `{ workspaceRemoteDir,
// runtimeRootDir, assetDirs, restoreWorkspace }`. Done once here, before the
// bridges, so both bridges receive the real (non-null) `runtimeRootDir`.
//
// PR 2: on the remote lane, delegate staging to the per-adapter
// `prepareRemoteManagedHome` seam when the adapter supplies one. The seam
// ships the adapter's managed home as an `assets` entry (through the `stage`
// callback = `stageAcpRemoteRuntime`), repoints the home env var (`env`) onto
// the in-sandbox `assetDirs.*` path, and returns a `teardown` that fires the
// codex auth copy-back (`restoreWorkspace()`) and removes staged temp dirs.
// Without a seam (custom agents / shared-engine tests) the engine stages the
// workspace with no home asset — identical to the PR-1 behavior.
let stagedRuntime: PreparedAdapterExecutionTargetRuntime | null = null;
let remoteManagedHomeTeardown: (() => Promise<void>) | null = null;
if (useRemoteProcessSession) {
const stage = (assets: AdapterManagedRuntimeAsset[]) =>
stageAcpRemoteRuntime({
runId,
target: executionTarget,
adapterKey: input.engine.adapterType,
workspaceLocalDir: cwd,
timeoutSec,
assets,
onLog: input.ctx.onLog,
onRuntimeProgress: input.ctx.onRuntimeProgress,
});
if (input.deps.prepareRemoteManagedHome) {
const seeded = await input.deps.prepareRemoteManagedHome({
acpxAgent,
companyId: agent.companyId,
runId,
config,
executionTarget,
workspaceLocalDir: cwd,
timeoutSec,
env,
onLog: input.ctx.onLog,
onRuntimeProgress: input.ctx.onRuntimeProgress,
stage,
});
stagedRuntime = seeded.stagedRuntime;
remoteManagedHomeTeardown = seeded.teardown ?? null;
} else {
stagedRuntime = await stage([]);
}
}
// The ACP `session/new` cwd and every cwd-keyed session-state site
// (fingerprint, compat, persist, ensureSession, error) bind to THIS single
// value so a warm/resumable session created with the in-sandbox cwd is reused
// — not invalidated — on the next run. Remote runner-backed → the staged
// in-sandbox workspace dir; local and the runner-less fallback → the HOST cwd,
// — not invalidated — on the next run. Remote runner-backed → the in-sandbox
// workspace dir; local and the runner-less fallback → the HOST cwd,
// byte-identical to today.
const sessionCwd = stagedRuntime?.workspaceRemoteDir ?? cwd;
let paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null = null;
if (useRemoteProcessSession) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: { ...executionTarget, streamRunLogs: false },
runtimeRootDir: stagedRuntime?.runtimeRootDir ?? null,
adapterKey: input.engine.adapterType,
timeoutSec,
hostApiToken: env.PAPERCLIP_API_KEY,
onLog: input.ctx.onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
await input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n");
}
}
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
let processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null = null;
try {
processSessionBridge = useRemoteProcessSession
? await startAdapterExecutionTargetProcessSessionBridge({
runId,
target: executionTarget,
runtimeRootDir: stagedRuntime?.runtimeRootDir ?? null,
adapterKey: input.engine.adapterType,
command: "sh",
args: ["-lc", `exec ${agentCommandShell}`],
cwd: sessionCwd,
env: runtimeEnv,
timeoutSec,
onLog: input.ctx.onLog,
})
: null;
} catch (err) {
await paperclipBridge?.stop().catch(() => {});
// The staged home / copy-back teardown must run even if a bridge fails to
// start after the workspace + managed home were already staged into the
// sandbox, so a refreshed credential is copied back and staged temp dirs
// are removed on this error path too.
await remoteManagedHomeTeardown?.().catch(() => {});
throw err;
}
const overrideCommand = processSessionBridge?.agentCommand ?? agentCommand;
const overrides = overrideCommand ? { [acpxAgent]: overrideCommand } : undefined;
const agentRegistry = createAgentRegistry({ overrides });
//
// PR 3: the staging transport derives the in-sandbox workspace dir
// deterministically from the target's `remoteCwd` (it is exactly `remoteCwd`
// for the sandbox transport), so we resolve `sessionCwd` — and therefore the
// session fingerprint / cache key — BEFORE staging. That lets a compatible
// resume decide to reuse an already-staged runtime instead of re-shipping the
// workspace / re-seeding the managed home. The stage call below pins its
// `workspaceRemoteDir` to this same value, so the staged cwd can never
// diverge from the cwd that fed the fingerprint.
const sessionCwd =
useRemoteProcessSession && executionTarget?.kind === "remote"
? executionTarget.remoteCwd
: cwd;
const fingerprint = shortHash({
acpxAgent,
agentCommand: agentCommand ?? acpxAgent,
@ -1469,6 +1490,223 @@ async function buildRuntime(input: {
});
const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default";
const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`;
// Ship the workspace into the sandbox and capture `{ workspaceRemoteDir,
// runtimeRootDir, assetDirs, restoreWorkspace }`. Done once here, before the
// bridges, so both bridges receive the real (non-null) `runtimeRootDir`.
//
// PR 2: on the remote lane, delegate staging to the per-adapter
// `prepareRemoteManagedHome` seam when the adapter supplies one. The seam
// ships the adapter's managed home as an `assets` entry (through the `stage`
// callback = `stageAcpRemoteRuntime`), repoints the home env var (`env`) onto
// the in-sandbox `assetDirs.*` path, and returns a `teardown` (per-run codex
// auth copy-back via `restoreWorkspace()`) plus a `disposeStaged` (one-time
// staged-temp cleanup). Without a seam (custom agents / shared-engine tests)
// the engine stages the workspace with no home asset — identical to PR-1.
//
// PR 3 (stage once per session): a COMPATIBLE resume whose fingerprint matches
// this exact `sessionKey` reuses the already-staged in-sandbox runtime — no
// workspace re-ship, no home re-seed — while an incompatible fingerprint (a
// different key) misses the cache and stages fresh. The `sessionKey`
// (`companyId:agentId:taskKey:fingerprint`) is the single scoping key, so one
// session can never read another session's staged credentials. The cache is
// populated by the executor only after a clean turn and dropped on
// failure/cancel/timeout, so it always holds a known-good staged runtime.
//
// Two guards close the concurrency / cross-session windows Greptile flagged:
// * Compatibility gate: reuse only when the supplied session params actually
// resume THIS staged session (the same `isCompatibleSession` predicate the
// warm-handle path uses). A fresh invocation with missing/cleared
// `sessionParams` starts a new ACP session via `session/new`, so it must
// NOT inherit the prior session's staged home/credentials — it stages
// fresh even when company/agent/task/fingerprint (and hence sessionKey)
// collide.
// * Per-key staging lock: the stage-or-reuse decision runs under a
// `sessionKey` mutex so two overlapping runs of the same session can never
// ship into the same remote workspace at once (the loser waits, then
// re-checks the cache before deciding).
const stagedRuntimes = input.deps.stagedRuntimes ?? defaultStagedRuntimes;
const stagingLocks = input.deps.stagingLocks ?? defaultStagingLocks;
const nowMs = input.deps.now ?? (() => Date.now());
const previousParams = parseObject(input.ctx.runtime.sessionParams);
const isCompatibleResume = isCompatibleSession(previousParams, {
fingerprint,
sessionKey,
cwd: sessionCwd,
mode,
acpxAgent,
remoteExecutionIdentity,
});
let stagedRuntime: PreparedAdapterExecutionTargetRuntime | null = null;
let remoteManagedHomeTeardown: (() => Promise<void>) | null = null;
let remoteStagingDispose: (() => Promise<void>) | null = null;
let remoteStagingEnvDelta: Record<string, string> | null = null;
let sessionStagingLeaseRelease: (() => void) | null = null;
if (useRemoteProcessSession && executionTarget?.kind === "remote") {
const remoteTarget = executionTarget;
const staged = await withSessionStagingLease(stagingLocks, sessionKey, async (): Promise<{
stagedRuntime: PreparedAdapterExecutionTargetRuntime;
teardown: (() => Promise<void>) | null;
dispose: (() => Promise<void>) | null;
envDelta: Record<string, string>;
}> => {
const cachedStaged = isCompatibleResume ? stagedRuntimes.get(sessionKey) : undefined;
if (cachedStaged) {
// Reuse the already-staged in-sandbox workspace + managed home. Re-apply
// the env keys the seam repointed onto the in-sandbox home (deterministic,
// identical across the session's runs) and reuse the seam's per-run
// copy-back so the codex auth copy-back still fires on THIS run's teardown
// — the copy-back cadence stays exactly per-run, unchanged from PR 2. The
// copy-back reads the sandbox auth.json live at teardown, so the reused
// closure copies back the current credential, never a stale snapshot, and
// it never removes the staged in-sandbox home (host staged-temp cleanup
// moved to `dispose`, fired only when the entry is dropped), so reusing it
// can't leave this run without its staged home.
// (The workspace restore in that same closure diffs against the ORIGINAL
// staging run's host baseline — an accepted consequence of "reuse, don't
// re-ship": the in-sandbox workspace is the source of truth mid-session
// and the host stays synced from it each run.)
Object.assign(env, cachedStaged.envDelta);
cachedStaged.lastUsedAt = nowMs();
await input.ctx.onLog(
"stdout",
"[paperclip] Reusing the staged in-sandbox runtime for this resumed session (no workspace re-ship / managed-home re-seed).\n",
);
return {
stagedRuntime: cachedStaged.stagedRuntime,
teardown: cachedStaged.teardown,
dispose: cachedStaged.dispose,
envDelta: cachedStaged.envDelta,
};
}
// Not a compatible resume (or no cache entry): stage fresh. If a stale
// entry sits at this key (e.g. an incompatible new session colliding on
// company/agent/task/fingerprint), drop it and release its host staged
// resources first so we neither reuse nor leak it.
const stale = stagedRuntimes.get(sessionKey);
if (stale) {
stagedRuntimes.delete(sessionKey);
if (stale.dispose) await stale.dispose().catch(() => {});
}
const stage = (assets: AdapterManagedRuntimeAsset[]) =>
stageAcpRemoteRuntime({
runId,
target: remoteTarget,
adapterKey: input.engine.adapterType,
workspaceLocalDir: cwd,
workspaceRemoteDir: sessionCwd,
timeoutSec,
assets,
onLog: input.ctx.onLog,
onRuntimeProgress: input.ctx.onRuntimeProgress,
});
// Snapshot env before the seam so we can capture exactly which keys it
// repointed onto the in-sandbox home (e.g. `CODEX_HOME`) and replay them
// verbatim on a later compatible resume. Add/change only — every seam sets
// (never deletes) its home env var, so a set-based delta is complete.
const envBeforeStage = { ...env };
let freshStagedRuntime: PreparedAdapterExecutionTargetRuntime;
let freshTeardown: (() => Promise<void>) | null = null;
let freshDispose: (() => Promise<void>) | null = null;
if (input.deps.prepareRemoteManagedHome) {
const seeded = await input.deps.prepareRemoteManagedHome({
acpxAgent,
companyId: agent.companyId,
runId,
config,
executionTarget: remoteTarget,
workspaceLocalDir: cwd,
timeoutSec,
env,
onLog: input.ctx.onLog,
onRuntimeProgress: input.ctx.onRuntimeProgress,
stage,
});
freshStagedRuntime = seeded.stagedRuntime;
freshTeardown = seeded.teardown ?? null;
freshDispose = seeded.disposeStaged ?? null;
} else {
freshStagedRuntime = await stage([]);
}
const delta: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (envBeforeStage[key] !== value) delta[key] = value;
}
return {
stagedRuntime: freshStagedRuntime,
teardown: freshTeardown,
dispose: freshDispose,
envDelta: delta,
};
});
sessionStagingLeaseRelease = staged.release;
stagedRuntime = staged.value.stagedRuntime;
remoteManagedHomeTeardown = staged.value.teardown;
remoteStagingDispose = staged.value.dispose;
remoteStagingEnvDelta = staged.value.envDelta;
}
// Both bridge starts run under one try so a failure at EITHER — including the
// paperclip callback bridge — fires the same abandon-path cleanup. The
// paperclip bridge starts after the workspace + managed home were already
// staged and the per-session staging lease is already held, so leaving it
// outside the catch would strand the lease (and the staged temp) on a
// start failure and deadlock the next run of this session.
let paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null = null;
let processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null = null;
let runtimeEnv: Record<string, string> = {};
try {
if (useRemoteProcessSession) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: { ...executionTarget, streamRunLogs: false },
runtimeRootDir: stagedRuntime?.runtimeRootDir ?? null,
adapterKey: input.engine.adapterType,
timeoutSec,
hostApiToken: env.PAPERCLIP_API_KEY,
onLog: input.ctx.onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
await input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n");
}
}
runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
processSessionBridge = useRemoteProcessSession
? await startAdapterExecutionTargetProcessSessionBridge({
runId,
target: executionTarget,
runtimeRootDir: stagedRuntime?.runtimeRootDir ?? null,
adapterKey: input.engine.adapterType,
command: "sh",
args: ["-lc", `exec ${agentCommandShell}`],
cwd: sessionCwd,
env: runtimeEnv,
timeoutSec,
onLog: input.ctx.onLog,
})
: null;
} catch (err) {
await paperclipBridge?.stop().catch(() => {});
// The staged home / copy-back teardown must run even if a bridge fails to
// start after the workspace + managed home were already staged into the
// sandbox, so a refreshed credential is copied back on this error path too.
// This run never reaches the executor, so also fire the one-time staged-temp
// dispose here (it no longer rides the per-run copy-back) — the run is being
// abandoned, so its staged temp must be released — and release the per-session
// staging lease so the abandoned run does not strand the next same-session run
// (cleanupRemoteBridges, which normally releases it, is never reached here).
await remoteManagedHomeTeardown?.().catch(() => {});
await remoteStagingDispose?.().catch(() => {});
sessionStagingLeaseRelease?.();
throw err;
}
const overrideCommand = processSessionBridge?.agentCommand ?? agentCommand;
const overrides = overrideCommand ? { [acpxAgent]: overrideCommand } : undefined;
const agentRegistry = createAgentRegistry({ overrides });
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
@ -1503,6 +1741,9 @@ async function buildRuntime(input: {
paperclipBridge,
stagedRuntime,
remoteManagedHomeTeardown,
remoteStagingDispose,
remoteStagingEnvDelta,
sessionStagingLeaseRelease,
remoteExecutionIdentity,
skillPromptInstructions,
skillsIdentity: {
@ -1584,6 +1825,7 @@ async function cleanupRemoteBridges(prepared: AcpxPreparedRuntime): Promise<void
if (prepared.remoteManagedHomeTeardown) {
await prepared.remoteManagedHomeTeardown().catch(() => {});
}
prepared.sessionStagingLeaseRelease?.();
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
@ -2044,6 +2286,116 @@ async function cleanupIdleHandles(input: {
}
}
// Drop staged-runtime entries the session has not touched within the warm-idle
// window, so the cache does not accumulate abandoned sessions (e.g. every time
// a config change shifts the fingerprint to a new key). The per-run copy-back
// already ran on the entry's last run's `cleanupRemoteBridges`; eviction fires
// the entry's one-time `dispose` (host staged-temp cleanup) — the only place
// the staged temp is removed now that it no longer rides the per-run teardown.
// A later run of the same session simply re-stages fresh (re-shipping into the
// still-persistent sandbox, which the inbound monotonic auth-merge keeps safe).
async function cleanupIdleStagedRuntimes(input: {
handles: Map<string, StagedRuntimeCacheEntry>;
locks: Map<string, Promise<unknown>>;
now: () => number;
idleMs: number;
}) {
if (input.idleMs <= 0) return;
const stale: Array<[string, StagedRuntimeCacheEntry]> = [];
for (const entry of input.handles.entries()) {
if (input.now() - entry[1].lastUsedAt >= input.idleMs) stale.push(entry);
}
for (const [key, entry] of stale) {
const lease = await withSessionStagingLease(input.locks, key, async () => {
const current = input.handles.get(key);
if (current !== entry) return;
if (input.now() - current.lastUsedAt < input.idleMs) return;
input.handles.delete(key);
if (entry.dispose) await entry.dispose().catch(() => {});
});
lease.release();
}
}
// Persist a remote runner-backed session's staged runtime for reuse on the next
// compatible resume. Called ONLY after a clean turn, so the cache never offers a
// half-staged or failed session for reuse. Non-remote lanes carry a null
// stagedRuntime / null envDelta and are skipped.
function saveStagedRuntimeAfterCleanTurn(input: {
handles: Map<string, StagedRuntimeCacheEntry>;
prepared: AcpxPreparedRuntime;
now: number;
}) {
const { prepared } = input;
if (!prepared.stagedRuntime || prepared.remoteStagingEnvDelta === null) return;
input.handles.set(prepared.sessionKey, {
stagedRuntime: prepared.stagedRuntime,
envDelta: prepared.remoteStagingEnvDelta,
teardown: prepared.remoteManagedHomeTeardown,
dispose: prepared.remoteStagingDispose,
lastUsedAt: input.now,
});
}
// Drop the staged-runtime entry a finished run owns and release its host-side
// staged resources. Two guards make this safe under overlapping runs of the same
// session key (PR 3 fix — "Concurrent Runs Corrupt Cache Ownership"):
// 1. Ownership guard: only delete the map entry when it is still the exact
// staged runtime THIS run installed/reused (object identity). A concurrent
// run that installed a different clean entry keeps it — a failed run can no
// longer evict another run's good cache entry.
// 2. `dispose` is fired for THIS run's own staged resources regardless, so a
// failed/cancelled run always frees its own staged temp. `dispose` is
// idempotent, so a shared closure re-fired across a reuse chain is safe.
async function discardStagedRuntime(input: {
handles: Map<string, StagedRuntimeCacheEntry>;
prepared: AcpxPreparedRuntime;
}): Promise<void> {
const { handles, prepared } = input;
const existing = handles.get(prepared.sessionKey);
if (existing && prepared.stagedRuntime && existing.stagedRuntime === prepared.stagedRuntime) {
handles.delete(prepared.sessionKey);
}
if (prepared.remoteStagingDispose) await prepared.remoteStagingDispose().catch(() => {});
}
// Per-`sessionKey` async lease: chains each caller after the previous one so
// the stage-or-reuse decision for a session runs serially, then keeps the
// lease held until the active turn finishes and bridge cleanup runs. That means
// overlapping runs of the same session can never stage fresh into the same
// remote workspace while a prior turn is still using it: the loser waits, then
// re-checks the cache before deciding to reuse or re-stage.
async function withSessionStagingLease<T>(
locks: Map<string, Promise<unknown>>,
key: string,
fn: () => Promise<T>,
): Promise<{ value: T; release: () => void }> {
const prev = locks.get(key) ?? Promise.resolve();
let releaseGate!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
// The next waiter's `prev` is this promise; it settles only once we release
// the gate below, so callers run one at a time.
const mine: Promise<unknown> = prev.then(() => gate);
locks.set(key, mine);
await prev.catch(() => {});
let released = false;
const release = () => {
if (released) return;
released = true;
releaseGate();
// GC the lock if no later caller has chained after us.
if (locks.get(key) === mine) locks.delete(key);
};
try {
return { value: await fn(), release };
} catch (error) {
if (!released) release();
throw error;
}
}
function clearWarmHandleTimer(entry: RuntimeCacheEntry) {
if (!entry.cleanupTimer) return;
clearTimeout(entry.cleanupTimer);
@ -2112,6 +2464,8 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
const createRuntime = deps.createRuntime ?? createAcpRuntime;
const now = deps.now ?? (() => Date.now());
const warmHandles = deps.warmHandles ?? defaultWarmHandles;
const stagedRuntimes = deps.stagedRuntimes ?? defaultStagedRuntimes;
const stagingLocks = deps.stagingLocks ?? defaultStagingLocks;
const engine = resolveEngineSettings(deps);
return async function executeAcpxEngine(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
@ -2126,6 +2480,16 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
...(billingIdentity?.biller ? { biller: billingIdentity.biller } : {}),
billingType: billingIdentity?.billingType ?? ("unknown" as const),
};
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS);
// Evict idle staged runtimes BEFORE building the runtime, since buildRuntime
// consults the staged cache to decide whether a compatible resume may reuse
// an already-staged runtime — an expired entry must not be reused.
await cleanupIdleStagedRuntimes({
handles: stagedRuntimes,
locks: stagingLocks,
now,
idleMs: warmIdleMs,
});
const prepared = await buildRuntime({ ctx, engine, deps });
// State the effective wall-clock timeout and its source up front so a
// later timeout is diagnosable from the run log alone. Goes to stderr:
@ -2135,7 +2499,6 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
"stderr",
`[paperclip] ${formatAdapterExecutionTimeoutStartLogLine(prepared.timeoutResolution)}\n`,
);
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS);
await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
const previousParams = parseObject(ctx.runtime.sessionParams);
@ -2209,6 +2572,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
err,
phase: "ensure_session",
});
await discardStagedRuntime({ handles: stagedRuntimes, prepared });
await cleanupRemoteBridges(prepared);
return {
exitCode: 1,
@ -2225,6 +2589,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
}
if (!handle) {
await discardStagedRuntime({ handles: stagedRuntimes, prepared });
await cleanupRemoteBridges(prepared);
return {
exitCode: 1,
@ -2263,6 +2628,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
await discardStagedRuntime({ handles: stagedRuntimes, prepared });
await cleanupRemoteBridges(prepared);
return {
exitCode: 1,
@ -2438,6 +2804,17 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
}
}
// PR 3: keep the staged runtime warm for the next compatible resume only
// after a clean turn; a failed/cancelled/timed-out turn discards it so the
// next run stages fresh instead of reusing a torn-down session's staged
// credentials. Copy-back still fires for every outcome via
// `cleanupRemoteBridges` below (unchanged from PR 2).
if (terminal.status === "completed" && !timedOut) {
saveStagedRuntimeAfterCleanTurn({ handles: stagedRuntimes, prepared, now: now() });
} else {
await discardStagedRuntime({ handles: stagedRuntimes, prepared });
}
const errorMessage = timedOut
? formatAdapterExecutionTimeoutErrorMessage(prepared.timeoutResolution)
: resultErrorMessage(terminal);
@ -2498,6 +2875,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
await discardStagedRuntime({ handles: stagedRuntimes, prepared });
const { classified, message } = await emitAcpxFailure({
ctx,
prepared,

View File

@ -92,6 +92,16 @@ function subscriptionAuthJson(accountId: string, lastRefresh: string, marker: st
);
}
// Enumerate the host staged-home temp dirs `stageCodexHomeForSync` created for a
// given runId (`paperclip-codex-home-sync-<runId>-<random>` under os.tmpdir()).
// A unique per-test runId scopes the match to this run's staging dirs only, so
// the assertion is not disturbed by other tests/processes sharing the tmp dir.
async function listCodexHomeSyncDirs(runId: string): Promise<string[]> {
const prefix = `paperclip-codex-home-sync-${runId}-`;
const entries = await fs.readdir(os.tmpdir());
return entries.filter((name) => name.startsWith(prefix)).map((name) => path.join(os.tmpdir(), name));
}
function setNodeVersion(version: string): void {
Object.defineProperty(process, "version", {
configurable: true,
@ -788,6 +798,158 @@ describe("codex_local ACP lane", () => {
expect(hostAuth.tokens.refresh_token).toBe("ref-host-newer");
});
it("keeps the host staged Codex home after a clean teardown so a compatible resume can reuse it", async () => {
// Session-re-staging guardrail: the per-run copy-back (`teardown`) must NOT
// remove the host staged-home temp dir — that removal moved to the one-time
// `disposeStaged`, fired only when the runtime is dropped. So after a CLEAN
// turn the engine caches the staged runtime warm and its host staged home is
// still on disk for the next compatible resume to reuse.
const runId = "run-keep-staged-home";
const root = await makeTempRoot("paperclip-codex-acp-keep-staged-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
// Strictly-newer sandbox credential so the per-run copy-back has real work.
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-same", NEWER_REFRESH, "sandbox-newer"),
{ mode: 0o600 },
);
await fs.writeFile(
path.join(sharedHostHome, "auth.json"),
subscriptionAuthJson("acct-same", OLDER_REFRESH, "host-older"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
// Isolated staged-runtime cache so this test observes only its own entry.
const stagedRuntimes = new Map();
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
stagedRuntimes,
stagingLocks: new Map(),
});
const result = await execute(
buildContext(localCwd, {
runId,
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}),
);
expect(result.exitCode).toBe(0);
// Guardrail: `teardown` ran the copy-back but left the host staged home in
// place, and the clean turn cached the staged runtime warm for reuse.
const stagedDirs = await listCodexHomeSyncDirs(runId);
expect(stagedDirs).toHaveLength(1);
await expect(fs.stat(stagedDirs[0]!)).resolves.toBeDefined();
expect(stagedRuntimes.size).toBe(1);
// The per-run copy-back still fired: the strictly-newer sandbox credential
// landed on the shared host under the monotonic guard.
const hostAuth = JSON.parse(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8"));
expect(hostAuth.last_refresh).toBe(NEWER_REFRESH);
expect(hostAuth.tokens.refresh_token).toBe("ref-sandbox-newer");
// No `disposeStaged` fires while the entry stays warm, so remove the
// intentionally-persisted staged temp ourselves to avoid leaking it.
await Promise.all(stagedDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
it("removes the host staged Codex home when a failed turn drops the staged runtime", async () => {
// The complementary guardrail: when the staged runtime IS dropped (here, a
// failed turn), the one-time `disposeStaged` fires and removes the host
// staged-home temp dir — while the per-run copy-back (`teardown`) STILL fires
// on the unclean exit path, so a rotated sandbox credential is never lost.
const runId = "run-drop-staged-home";
const root = await makeTempRoot("paperclip-codex-acp-drop-staged-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-same", NEWER_REFRESH, "sandbox-newer"),
{ mode: 0o600 },
);
await fs.writeFile(
path.join(sharedHostHome, "auth.json"),
subscriptionAuthJson("acct-same", OLDER_REFRESH, "host-older"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
const stagedRuntimes = new Map();
const execute = createCodexAcpExecutor({
// A failed turn drives the drop path (discard staged runtime + dispose).
createRuntime: (options: FakeRuntimeOptions) =>
new FakeRuntime(options, [], { status: "failed", stopReason: "error" }) as never,
stagedRuntimes,
stagingLocks: new Map(),
});
const result = await execute(
buildContext(localCwd, {
runId,
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
} as never,
authToken: "real-run-jwt",
}),
);
expect(result.exitCode).toBe(1);
// Guardrail: the dropped staged runtime disposed its host staged home and
// left nothing cached for reuse.
await expect(listCodexHomeSyncDirs(runId)).resolves.toEqual([]);
expect(stagedRuntimes.size).toBe(0);
// ...yet the per-run copy-back still ran on the failure teardown path, so the
// strictly-newer sandbox credential was not lost.
const hostAuth = JSON.parse(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8"));
expect(hostAuth.last_refresh).toBe(NEWER_REFRESH);
expect(hostAuth.tokens.refresh_token).toBe("ref-sandbox-newer");
});
it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => {
setNodeVersion("v22.13.0");
// Isolate the missing bidirectional runner as the sole fallback cause:

View File

@ -204,6 +204,13 @@ async function prepareCodexRemoteManagedHome(
return {
stagedRuntime,
// Per-run copy-back: fires on EVERY run's teardown (including a compatible
// resume that reuses this staged runtime). It reads the sandbox auth.json /
// workspace live and copies back to the host; it does NOT remove the staged
// in-sandbox home, so re-running it across resumes can't leave a later run
// without its staged home. Host staged-temp removal is deliberately NOT here
// — see `disposeStaged` — so caching this runtime for reuse never destroys
// resources the next resume needs.
teardown: async () => {
try {
await onLog(
@ -222,17 +229,23 @@ async function prepareCodexRemoteManagedHome(
err instanceof Error ? err.message : String(err)
}\n`,
);
} finally {
await fs.rm(stagedCodexHomeDir, { recursive: true, force: true }).catch(async (error) => {
await onLog(
"stderr",
`[paperclip] Failed to remove staged Codex home "${stagedCodexHomeDir}": ${
error instanceof Error ? error.message : String(error)
}\n`,
);
});
}
},
// One-time cleanup of the HOST staged home temp dir. Fired ONLY when the
// staged runtime is dropped (failed/cancelled/timed-out turn, incompatible
// re-stage, idle eviction) — never on a clean turn that keeps the runtime
// warm — so it can't remove the staged home while a reuse still depends on
// it. Idempotent: `force: true` no-ops if it was already removed.
disposeStaged: async () => {
await fs.rm(stagedCodexHomeDir, { recursive: true, force: true }).catch(async (error) => {
await onLog(
"stderr",
`[paperclip] Failed to remove staged Codex home "${stagedCodexHomeDir}": ${
error instanceof Error ? error.message : String(error)
}\n`,
);
});
},
};
}