mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 01:25:26 +02:00
## 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>
80 lines
3.2 KiB
TypeScript
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;
|
|
}
|