mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
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:
3 files changed
+223
-37
No files matched your search
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user