Files
PaperClipAI/packages/adapter-utils/src/sync-operation-schedule.ts
T
Nicky LeachandPaperclip 233be4b36c feat: parallelize sandbox file-sync behind a provider opt-in capability (#11736)
## Thinking Path

> - Paperclip runs AI agents through local and remote execution
adapters.
> - Sandbox providers move workspace and asset files before and after
agent runs.
> - Serial file transfers delay startup and teardown when several
operations do not depend on each other.
> - Providers need an opt-in contract so existing providers keep their
serial behavior.
> - This pull request adds a bounded scheduler and routes inbound and
outbound sync operations through it.
> - The benefit is shorter sandbox setup and teardown with stable
errors, clear telemetry, and a safe opt-in path.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting (multiple of the above): packages/shared,
packages/adapter-utils, packages/plugins, and server.

**Problem or motivation**

Sandbox sync processes the workspace, assets, and referenced projects in
series. This adds avoidable wait time to agent startup and teardown.

**Proposed solution**

Add a fail-closed provider capability named concurrentSyncOperations.
Use a bounded scheduler with a limit of four operations. Preserve
operation order for error reporting. Keep non-opted-in providers on the
serial path.

**Alternatives considered**

Increase the serial transfer speed or add provider-specific schedulers.
Those options do not provide one shared contract or stable behavior
across providers.

**Roadmap alignment**

ROADMAP.md lists cloud and sandbox agents as a product area. This change
improves sandbox execution without changing the control-plane contract.

**Additional context**

The Daytona provider opts in. Board trials on this commit showed overlap
for inbound sync and outbound restore, with no referenced-project
staging failures.

## What Changed

- Add the concurrentSyncOperations sandbox capability and fail-closed
parsing.
- Add a bounded settle-all scheduler with stable input-order errors.
- Parallelize inbound workspace, asset, and referenced-project sync
operations when the provider opts in.
- Parallelize outbound workspace and asset restore operations when the
provider opts in.
- Surface referenced-project failure text in run logs and server
telemetry.
- Add Daytona sync spans and the capability declaration.
- Preserve in-flight upload scratch tarballs during workspace wipe.
- Add unit and regression tests for the scheduler, coordinators,
provider behavior, telemetry, and wipe race.

## Verification

- Run the adapter-utils and server type checks.
- Run the targeted adapter-utils, server, and Daytona test suites.
- Run the full automated sweep.
- Review six cold Daytona trials, with three serial and three parallel
runs.
- Confirm that parallel trials show inbound overlap and outbound restore
overlap.
- Confirm that providers without the capability keep serial behavior.

## Risks

- Providers must opt in only when their file operations can run safely
at the same time.
- A provider that declares the capability incorrectly can expose
transfer races.
- The scheduler keeps a limit of four to bound resource use.
- Providers without the capability keep the prior serial behavior.

## Model Used

OpenAI GPT-5 in the Codex runtime. The model used tool calls, code
inspection, and GitHub workflow support. The model did not author the
implementation commits.

## 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 and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] 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>
2026-08-19 12:34:11 -07:00

80 lines
3.2 KiB
TypeScript

// A direction-agnostic scheduler for file-sync operations. Both sync directions
// share this one function. The scheduler starts operation tasks under a bound,
// keeps serial order when concurrency is off, and settles every started task
// before it returns. It stays pure: it imports no runtime or provider module.
// The maximum number of active sync operations when concurrency is on. A caller
// passes this as the bound. Four keeps a useful parallel width without a burst
// of open file handles.
export const SYNC_OPERATION_CONCURRENCY_LIMIT = 4;
// A sync operation. The scheduler calls the thunk to start the operation, so the
// scheduler controls the exact start time and can hold the bound.
export type SyncOperationTask<T> = () => Promise<T>;
// Resolve the number of active tasks the scheduler allows.
// Serial mode allows one active task. Concurrent mode allows the bound, with a
// floor of one so a bad bound never stalls the scheduler.
function resolveActiveLimit(concurrent: boolean, bound: number): number {
if (!concurrent) {
return 1;
}
const flooredBound = Math.floor(bound);
if (!Number.isFinite(flooredBound) || flooredBound < 1) {
return 1;
}
return flooredBound;
}
/**
* Run an ordered list of sync operation tasks and settle every started task.
*
* When `concurrent` is false, the scheduler runs the tasks one at a time in
* input order. When `concurrent` is true, the scheduler keeps at most `bound`
* tasks active. The scheduler always waits for every started task to settle
* before it returns. It returns the settled results in input order.
*
* @param tasks Ordered task list. The scheduler calls each thunk to start it.
* @param concurrent Turn concurrency on or off.
* @param bound Maximum active tasks when `concurrent` is true.
* @returns Settled results in input order, one per task.
*/
export async function scheduleSyncOperations<T>(
tasks: ReadonlyArray<SyncOperationTask<T>>,
concurrent: boolean,
bound: number = SYNC_OPERATION_CONCURRENCY_LIMIT,
): Promise<Array<PromiseSettledResult<T>>> {
const results = new Array<PromiseSettledResult<T>>(tasks.length);
const activeLimit = resolveActiveLimit(concurrent, bound);
// A shared cursor over the input list. Each worker takes the next task in
// input order. The cursor keeps serial order and holds the active bound.
let nextIndex = 0;
// One worker settles tasks until the list is empty. A worker catches its own
// task rejection, so a rejection never stops the other workers and never
// rejects the scheduler. This guarantees settle-all.
async function runWorker(): Promise<void> {
while (nextIndex < tasks.length) {
const currentIndex = nextIndex;
nextIndex += 1;
try {
const value = await tasks[currentIndex]();
results[currentIndex] = { status: "fulfilled", value };
} catch (reason) {
results[currentIndex] = { status: "rejected", reason };
}
}
}
// Start at most `activeLimit` workers, and never more than the task count.
const workerCount = Math.min(activeLimit, tasks.length);
const workers: Array<Promise<void>> = [];
for (let worker = 0; worker < workerCount; worker += 1) {
workers.push(runWorker());
}
await Promise.all(workers);
return results;
}