perf(adapter-utils): parallelize the two Daytona sandbox bridge setups (#10334)

## Thinking Path

> - Paperclip runs AI-agent work through adapter-backed execution paths.
> - Sandbox-backed local adapters pay startup overhead in two host-side
bridge setups.
> - Those setups were happening serially even though most of the work is
independent.
> - The remaining dependency is only the merged env that must reach the
process-session launch.
> - This pull request overlaps the independent setup work while
preserving that launch-time dependency.
> - The benefit is lower end-to-end startup latency without changing
execution semantics.

## Linked Issues or Issue Description

No public GitHub issue exists for this change.

Problem / motivation:
- Sandbox-backed local adapter startup waited for two largely
independent bridge setups in series, so users paid roughly the sum of
both setup times.
- The only hard dependency is the merged Paperclip env that must reach
the process-session launch.

Proposed solution:
- Start the paperclip callback bridge and the process-session bridge
concurrently.
- Keep the env merge as the single sequencing point before
process-session launch.
- Stop whichever bridge started if either startup path fails.
- Keep the concurrent bridge telemetry duration-only so shared runner
counters are not double-counted.

Alternatives considered:
- Keep the bridges serial, which preserves the current telemetry shape
but leaves the startup latency unchanged.
- Move env merging earlier, which would complicate launch ordering and
risk changing runtime semantics.

Roadmap alignment:
- This is the approved Daytona start-speedup work, focused on bridge
startup concurrency rather than broader runtime behavior.

## What Changed

- Started the paperclip callback bridge and the process-session bridge
concurrently in `execute.ts`.
- Added a memoized env finalizer so the process-session launch still
waits for the merged paperclip env at the correct moment.
- Updated `execution-target.ts` so the process-session bridge can accept
a deferred env resolver and consume it only at launch time.
- Added tests that cover the overlapped launch path and the
failure-cleanup path when one bridge start fails.

## Verification

- `pnpm --filter @paperclipai/adapter-utils exec vitest run
src/acpx-engine/execute.test.ts`
- `pnpm --filter @paperclipai/adapter-utils test`
- `pnpm --filter @paperclipai/adapter-utils exec tsc --noEmit`
- `git log --oneline
origin/master..origin/perf/parallelize-daytona-bridge-setups`
- `git diff --stat
origin/master...origin/perf/parallelize-daytona-bridge-setups`

## Risks

- A regression in the launch-time env merge could change what the
process-session bridge sees at startup.
- The concurrent start/cleanup logic could leak a bridge if the stop
paths were incorrect, which is why the failure cleanup test matters.
- This is low-to-moderate risk because the change is isolated to
adapter-utils startup plumbing and is covered by targeted tests.

## Model Used

OpenAI Codex, GPT-5, tool-using code execution mode.

## 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
- [ ] 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: Harold Kim <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-27 15:26:11 -07:00 committed by GitHub
parent 273315a4d0
commit a3b293e26d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 223 additions and 37 deletions

View File

@ -1851,6 +1851,85 @@ describe("ACPX engine remote sandbox staging seam (PR 1: workspace + cwd)", () =
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
});
it("hands the merged paperclip env to the process-session launch when the setups overlap", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
// Decode the process-session LAUNCH payload (the base64 command blob) — the
// in-sandbox process env is carried there, NOT in the exec's own `env`.
let launchPayload: Record<string, unknown> | null = null;
(executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => {
if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") {
const script = input.args?.[1] ?? "";
const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/);
if (match) {
launchPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record<
string,
unknown
>;
}
}
});
await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{ authToken: "real-run-jwt", executionTarget },
);
// The process-session bridge receives its launch env as a DEFERRED thunk —
// the seam that lets its env-independent setup overlap the paperclip bridge
// start instead of running strictly after it.
const processArgs = vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mock.calls[0]![0];
expect(typeof processArgs.env).toBe("function");
// ...and despite the overlap the launch still observes the MERGED paperclip
// env: the paperclip-`env` → process-session-launch hand-off stays sequenced
// under concurrency (bridge base URL + minted bridge token both present, and
// the token is NOT the host run JWT).
const payloadEnv = ((launchPayload as Record<string, unknown> | null)?.env ?? {}) as Record<
string,
unknown
>;
expect(payloadEnv).toMatchObject({ PAPERCLIP_API_BRIDGE_MODE: "queue_v1" });
expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy();
expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
});
it("stops the process-session bridge when the paperclip bridge fails under concurrency", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
// The paperclip bridge fails; the process-session bridge — started CONCURRENTLY
// with it — still resolves a live handle. The abandon path must stop that
// handle so no started bridge leaks on partial failure.
const stop = vi.fn(async () => {});
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(async () => {
throw new Error("paperclip bridge boom");
});
vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mockImplementationOnce(
async () => ({ agentCommand: null, stop }) as never,
);
const execute = createAcpxEngineExecutor({
createRuntime: () => buildRuntime() as never,
});
await expect(
execute({
runId: "run-bridge-fail",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
context: {},
authToken: "real-run-jwt",
executionTarget,
onLog: async () => {},
onMeta: async () => {},
onEvent: async () => {},
} as never),
).rejects.toThrow("paperclip bridge boom");
// The concurrently-started process-session bridge was stopped exactly once.
expect(stop).toHaveBeenCalledTimes(1);
});
it("test_remote_session_new_uses_in_sandbox_cwd", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
const { sessionInputs, runtimeOptions } = await runExecutor(
@ -2795,7 +2874,7 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
}
});
it("carries per-step roundTrips + provider durations sourced from the sandbox runner counter", async () => {
it("carries roundTrips + provider durations for sequential startup steps and keeps concurrent bridge steps duration-only", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const localCwd = path.join(root, "worktree");
@ -2818,10 +2897,13 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
const steps = stepEvents(events);
const seen = new Map(steps.map((event) => [String(event.payload?.step), event]));
// Every timed boundary carries a numeric roundTrips (the runner exposes
// execCount), even the ones that never exec.
// Every timed boundary still records duration.
for (const event of steps) {
expect(typeof event.payload?.roundTrips).toBe("number");
expect(typeof event.payload?.durationMs).toBe("number");
}
// Sequential boundaries retain runner-counter attribution.
for (const step of ["workspace.resolve", "stage.sync", "acp.handshake"]) {
expect(typeof seen.get(step)?.payload?.roundTrips).toBe("number");
}
// workspace.resolve is host-only → zero host→sandbox execs.
expect(seen.get("workspace.resolve")?.payload?.roundTrips).toBe(0);
@ -2835,6 +2917,13 @@ describe("ACPX engine per-step startup timing (run.startup.step events)", () =>
expect(stageSync?.payload?.providerGetMs).toBe(
(stageSync?.payload?.roundTrips as number) * 15,
);
// Concurrent bridge steps are duration-only so they do not double-count
// shared runner counters while their lifecycles overlap.
for (const step of ["bridge.paperclip", "bridge.process-session"]) {
expect(seen.get(step)?.payload?.roundTrips).toBeUndefined();
expect(seen.get(step)?.payload?.providerExecMs).toBeUndefined();
expect(seen.get(step)?.payload?.providerGetMs).toBeUndefined();
}
// The external ACP client crosses no host exec seam.
expect(seen.get("acp.handshake")?.payload?.roundTrips).toBe(0);
});

View File

@ -1292,6 +1292,12 @@ async function buildRuntime(input: {
? executionTarget.runner
: undefined,
);
// The two bridge-start steps intentionally overlap, so their runner counters
// would double-count each other if we sampled them here. Keep the shared
// counter attribution on the sequential startup phases only; the concurrent
// bridge steps still emit duration telemetry, just not misleading per-step
// round-trip/provider deltas.
const concurrentBridgeStepMetrics: StartupStepMeasureOptions = {};
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: effectiveWorkspaceCwd,
workspaceWorktreePath,
@ -1747,8 +1753,21 @@ async function buildRuntime(input: {
let runtimeEnv: Record<string, string> = {};
try {
if (useRemoteProcessSession) {
// Step 5 — bridge.paperclip: start the sandbox ACP API callback bridge.
paperclipBridge = await measureStartupStep(input.ctx, nowMs, "bridge.paperclip", () =>
// Steps 5 + 6 — bring up BOTH host-side sandbox bridges concurrently. Their
// remote subtrees are disjoint (`…/paperclip-bridge/…` vs
// `…/process-sessions/…`), so the env-INDEPENDENT setup of each overlaps,
// trending wall time from serial (~bridge.paperclip + ~bridge.process-session)
// toward ~max(the two). The ONE real dependency — the paperclip bridge's
// returned `env` must reach the process-session LAUNCH — is sequenced by
// `finalizeLaunchEnv`: the process-session bridge runs its env-independent
// dir/script setup first, then awaits that thunk right before its launch, so
// the launch always observes the merged paperclip env.
//
// Measurement caveat: both starts share ONE runner counter, so their
// overlapping `providerExecMs`/`roundTrips` deltas are approximate (the same
// caveat as `acp.handshake`). Both `run.startup.step` events still emit —
// `measureStartupStep` records them in a `finally`, even on a start failure.
const paperclipStart = measureStartupStep(input.ctx, nowMs, "bridge.paperclip", () =>
startAdapterExecutionTargetPaperclipBridge({
runId,
target: { ...executionTarget, streamRunLogs: false },
@ -1758,38 +1777,59 @@ async function buildRuntime(input: {
hostApiToken: env.PAPERCLIP_API_KEY,
onLog: input.ctx.onLog,
}),
stepMetrics,
concurrentBridgeStepMetrics,
);
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
await input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n");
}
// The single sequencing point (paperclip `env` → process-session launch).
// Memoized so the merge + log + `runtimeEnv` build run EXACTLY once whether
// the process-session bridge consumes it at launch or we finalize it below.
let launchEnvPromise: Promise<Record<string, string>> | null = null;
const finalizeLaunchEnv = (): Promise<Record<string, string>> =>
(launchEnvPromise ??= (async () => {
const paperclip = await paperclipStart;
if (paperclip) {
Object.assign(env, paperclip.env);
await input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n");
}
return (runtimeEnv = resolveRuntimeEnv(env));
})());
const processSessionStart = measureStartupStep(input.ctx, nowMs, "bridge.process-session", () =>
startAdapterExecutionTargetProcessSessionBridge({
runId,
target: executionTarget,
runtimeRootDir: stagedRuntime?.runtimeRootDir ?? null,
adapterKey: input.engine.adapterType,
command: "sh",
args: ["-lc", `exec ${agentCommandShell}`],
cwd: sessionCwd,
// Deferred: the process-session bridge runs its env-independent setup,
// then calls this to get the launch env AFTER the paperclip env merge.
env: finalizeLaunchEnv,
timeoutSec,
onLog: input.ctx.onLog,
}),
concurrentBridgeStepMetrics,
);
// Settle BOTH starts (mirrors `cleanupRemoteBridges`' `Promise.allSettled`):
// collect whichever handles started plus the first failure. Both handles
// stay individually declared so the catch below can stop whichever started.
const started = await settleRemoteBridgeStarts(paperclipStart, processSessionStart);
paperclipBridge = started.paperclipBridge;
processSessionBridge = started.processSessionBridge;
if (started.failure) throw started.failure;
// Guarantee the paperclip env merge ran even if the process-session bridge
// returned without consuming the launch env (memoized ⇒ a no-op if it did).
await finalizeLaunchEnv();
} else {
// Local / runner-less lanes never start a bridge, but the returned prepared
// runtime and the log builder still read `runtimeEnv`.
runtimeEnv = resolveRuntimeEnv(env);
}
runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
// Step 6 — bridge.process-session: start the in-sandbox process session.
processSessionBridge = useRemoteProcessSession
? await measureStartupStep(input.ctx, nowMs, "bridge.process-session", () =>
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,
}),
stepMetrics,
)
: null;
} catch (err) {
await paperclipBridge?.stop().catch(() => {});
// On a partial concurrent bring-up failure, ONE bridge may have started while
// the other threw; `Promise.allSettled` stops whichever started so no live
// bridge leaks (mirrors `cleanupRemoteBridges`). Both handles are individually
// declared above, so either may be non-null here.
await Promise.allSettled([paperclipBridge?.stop(), processSessionBridge?.stop()]);
// 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.
@ -1916,6 +1956,54 @@ async function applySessionConfigOptions(input: {
}
}
/**
* Build the process-session launch env: the host env overlaid with the run's
* `env` (so the merged paperclip bridge vars win) and a guaranteed `PATH`,
* narrowed to string values. Shared by the remote concurrent bring-up and the
* local / runner-less lane so both resolve the runtime env identically.
*/
function resolveRuntimeEnv(env: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
/**
* Bring up the two host-side sandbox bridges concurrently and settle both.
*
* Mirrors `cleanupRemoteBridges`' `Promise.allSettled` idiom (settle, not
* `Promise.all`): running BOTH starts to completion is what lets the caller STOP
* a bridge that DID start when its sibling threw so a partial failure never
* leaks a live bridge. Returns whichever handles started plus the first failure
* (paperclip before process-session) for the caller to rethrow through the
* shared abandon path.
*/
async function settleRemoteBridgeStarts(
paperclipStart: Promise<AdapterExecutionTargetPaperclipBridgeHandle | null>,
processSessionStart: Promise<AdapterExecutionTargetProcessSessionBridgeHandle | null>,
): Promise<{
paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null;
processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null;
failure: unknown;
}> {
const [paperclip, processSession] = await Promise.allSettled([
paperclipStart,
processSessionStart,
]);
return {
paperclipBridge: paperclip.status === "fulfilled" ? paperclip.value : null,
processSessionBridge: processSession.status === "fulfilled" ? processSession.value : null,
failure:
paperclip.status === "rejected"
? paperclip.reason
: processSession.status === "rejected"
? processSession.reason
: null,
};
}
async function cleanupRemoteBridges(prepared: AcpxPreparedRuntime): Promise<void> {
await Promise.allSettled([
prepared.processSessionBridge?.stop(),

View File

@ -1303,7 +1303,12 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
command: string;
args: string[];
cwd: string;
env: Record<string, string>;
// The launch env is consumed ONLY when building the base64 `commandPayload`
// below — never during the env-INDEPENDENT dir/script setup. Accepting a
// resolver (in addition to a plain object) lets a caller overlap that setup
// with other work — e.g. starting the paperclip callback bridge — and hand the
// merged env in right before the launch.
env: Record<string, string> | (() => Promise<Record<string, string>>);
timeoutSec?: number | null;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<AdapterExecutionTargetProcessSessionBridgeHandle | null> {
@ -1339,11 +1344,15 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
await client.makeDir(eventsDir);
await syncProcessSessionRemoteScript({ client, remoteScriptPath });
// Resolve the launch env AFTER the env-independent setup above, so a caller
// can defer it until an upstream dependency (e.g. the paperclip bridge's env)
// is ready without blocking the dir/script setup.
const launchEnv = typeof input.env === "function" ? await input.env() : input.env;
const commandPayload = Buffer.from(JSON.stringify({
command: input.command,
args: input.args,
cwd: input.cwd || target.remoteCwd,
env: sanitizeRemoteExecutionEnv(input.env),
env: sanitizeRemoteExecutionEnv(launchEnv),
}), "utf8").toString("base64");
await onLog("stdout", `[paperclip] Starting ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`);