mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 06:02:18 +02:00
perf: stage independent remote GitHub launchers concurrently (#16484)
Stage at most three independent remote GitHub launcher files at once only when the existing effective concurrent-sync capability permits it. Preserve serial fallback, per-file locks and integrity checks, and settle every started upload before failure returns. Verification: 119 affected tests pass (one existing Linux-only oracle skipped on macOS); full repository typecheck/build pass; same-sandbox real-Daytona component comparison preserves all bytes/modes; current-head full CI/E2E/Superagent/Snyk and Apex 5/5 pass. The full native timing target remains separate and is not claimed by this change. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
676d3ec8d4
commit
6943904bad
3 files changed
+128
-10
No files matched your search
@@ -62,6 +62,13 @@ the same sanitized environment as execution, so a CLI visible only in the
|
||||
provider's default environment cannot pass the launch check. Failed path
|
||||
discovery stops startup instead of silently falling back to a minimal path.
|
||||
|
||||
Providers that declare concurrent sync support stage at most three independent
|
||||
launcher files at once. Other providers keep serial staging. Each file retains
|
||||
its content hash, upload lock, integrity check and atomic rename. A failed batch
|
||||
settles all started uploads before startup rejects; later files and the final
|
||||
permission step do not start. This changes startup scheduling, not credentials
|
||||
or callback transport selection.
|
||||
|
||||
Scripts that previously read a persistent `GH_TOKEN` must use managed `git`, `gh`, or GitHub gateway tools. Managed execution skips legacy GitHub token bindings in agent, environment, project, and routine configuration before secret preflight. Configure personal or dedicated access through the GitHub connection instead. Directly invoking an unmanaged executable or retaining a token obtained during an earlier invocation is outside the managed invocation contract.
|
||||
|
||||
## Legacy hosts and networking
|
||||
|
||||
@@ -1770,14 +1770,19 @@ export async function prepareGitHubOperationLaunchers(input: {
|
||||
]);
|
||||
if (remote) {
|
||||
const runner = adapterExecutionTargetCommandRunner(remote);
|
||||
for (const [program, body] of Object.entries(files)) {
|
||||
await syncRemoteTextFileWithHashSkip({
|
||||
runner, remoteCwd: remote.remoteCwd, remoteDir: directory,
|
||||
remotePath: path.posix.join(directory, program), body,
|
||||
label: "GitHub operation launcher", action: "stage GitHub operation launcher",
|
||||
lockDir: path.posix.join(directory, `.${program}.lock`),
|
||||
timeoutMs: 15_000, shellCommand: adapterExecutionTargetShellCommand(remote),
|
||||
});
|
||||
const entries = Object.entries(files);
|
||||
const width = runner.allowConcurrentSyncOperations === true ? 3 : 1;
|
||||
for (let offset = 0; offset < entries.length; offset += width) {
|
||||
const results = await Promise.allSettled(entries.slice(offset, offset + width).map(([program, body]) =>
|
||||
syncRemoteTextFileWithHashSkip({
|
||||
runner, remoteCwd: remote.remoteCwd, remoteDir: directory,
|
||||
remotePath: path.posix.join(directory, program), body,
|
||||
label: "GitHub operation launcher", action: "stage GitHub operation launcher",
|
||||
lockDir: path.posix.join(directory, `.${program}.lock`),
|
||||
timeoutMs: 15_000, shellCommand: adapterExecutionTargetShellCommand(remote),
|
||||
})));
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure?.status === "rejected") throw failure.reason;
|
||||
}
|
||||
const permissions = await runner.execute({ command: "sh", args: ["-c", `chmod 700 ${shellQuote(directory)}/git ${shellQuote(directory)}/gh && mkdir -p ${shellQuote(configDirectory)}`], cwd: remote.remoteCwd, timeoutMs: 15_000 });
|
||||
if (permissions.exitCode !== 0) throw new Error("Could not prepare managed GitHub launchers");
|
||||
|
||||
@@ -23,7 +23,7 @@ afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function sandbox(layout: string) {
|
||||
async function sandbox(layout: string, concurrent?: boolean) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-launcher-env-"));
|
||||
roots.push(root);
|
||||
const bin = path.join(root, layout);
|
||||
@@ -61,13 +61,119 @@ async function sandbox(layout: string) {
|
||||
stdout: result.stdout ?? "", stderr: result.stderr ?? "", pid: null, startedAt };
|
||||
}
|
||||
};
|
||||
const runner = { execute: vi.fn(execute) };
|
||||
const runner = { allowConcurrentSyncOperations: concurrent, execute: vi.fn(execute) };
|
||||
const target = { kind: "remote" as const, transport: "sandbox" as const,
|
||||
providerKey: "fixture", remoteCwd: root, runner };
|
||||
return { root, bin, remotePath, runner, target };
|
||||
}
|
||||
|
||||
describe("managed GitHub launcher environment", () => {
|
||||
it("bounds independent remote launcher uploads and preserves the staged environment", async () => {
|
||||
const fixture = await sandbox("tools with 'quotes'/bin", true);
|
||||
const execute = fixture.runner.execute.getMockImplementation()!;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
let started = 0;
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
fixture.runner.execute.mockImplementation(async (input) => {
|
||||
if (!input.stdin) return execute(input);
|
||||
started += 1;
|
||||
active += 1;
|
||||
maximum = Math.max(maximum, active);
|
||||
try {
|
||||
await gate;
|
||||
return await execute(input);
|
||||
} finally { active -= 1; }
|
||||
});
|
||||
const preparation = prepareGitHubOperationLaunchers({
|
||||
runId: "bounded-staging", target: fixture.target, cwd: fixture.root,
|
||||
env: githubBrokerEnvironment({ PATH: fixture.remotePath }, { url: "", token: "" }),
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(started).toBe(3));
|
||||
expect(active).toBe(3);
|
||||
} finally { release(); await preparation; }
|
||||
const env = await preparation;
|
||||
expect(maximum).toBe(3);
|
||||
expect(active).toBe(0);
|
||||
expect(started).toBe(9);
|
||||
expect(env.PATH).toBe(`${env.PAPERCLIP_GITHUB_LAUNCHER_DIR}:${fixture.remotePath}`);
|
||||
const command = await fixture.runner.execute({ command: "bash", args: ["--noprofile", "--norc", "-c", "git; gh"], env });
|
||||
expect(command.exitCode, command.stderr).toBe(0);
|
||||
expect(command.stdout).toBe("git started\ngh started\n");
|
||||
for (const name of [".profile", ".bash_profile", ".bashrc", ".zshenv", ".zprofile", ".zshrc"]) {
|
||||
const script = await readFile(path.join(env.PAPERCLIP_GITHUB_LAUNCHER_DIR, name), "utf8");
|
||||
const result = await fixture.runner.execute({ command: "sh", args: ["-c", `${script}\nprintf '%s' "$PATH"`] });
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toBe(env.PATH);
|
||||
}
|
||||
});
|
||||
|
||||
it("settles failed upload batches before rejecting and starting no later files", async () => {
|
||||
const fixture = await sandbox("usr/bin", true);
|
||||
const execute = fixture.runner.execute.getMockImplementation()!;
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
let started = 0;
|
||||
let active = 0;
|
||||
let settled = false;
|
||||
fixture.runner.execute.mockImplementation(async (input) => {
|
||||
if (!input.stdin) return execute(input);
|
||||
const index = started++;
|
||||
active += 1;
|
||||
try {
|
||||
if (index === 0) return { exitCode: 42, signal: null, timedOut: false, pid: null,
|
||||
startedAt: new Date().toISOString(), stdout: "", stderr: "fixture upload failed" };
|
||||
await gate;
|
||||
return await execute(input);
|
||||
} finally { active -= 1; }
|
||||
});
|
||||
const preparation = prepareGitHubOperationLaunchers({
|
||||
runId: "failed-batch", target: fixture.target, cwd: fixture.root, env: { PATH: fixture.remotePath },
|
||||
});
|
||||
const outcome = preparation.then(() => { settled = true; return null; }, (error: unknown) => { settled = true; return error; });
|
||||
try {
|
||||
await vi.waitFor(() => expect(started).toBe(3));
|
||||
expect(settled).toBe(false);
|
||||
expect(active).toBe(2);
|
||||
} finally { release(); await outcome; }
|
||||
expect(await outcome).toBeInstanceOf(Error);
|
||||
expect(String(await outcome)).toContain("stage GitHub operation launcher");
|
||||
expect(started).toBe(3);
|
||||
expect(active).toBe(0);
|
||||
expect(fixture.runner.execute.mock.calls.some(([input]) => input.args?.join(" ").startsWith("chmod 700"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps concurrent staging of the same remote launcher directory coherent", async () => {
|
||||
const fixture = await sandbox("usr/bin", true);
|
||||
const input = { runId: "same-directory", target: fixture.target, cwd: fixture.root,
|
||||
env: githubBrokerEnvironment({ PATH: fixture.remotePath }, { url: "", token: "" }) };
|
||||
const [first, second] = await Promise.all([prepareGitHubOperationLaunchers(input), prepareGitHubOperationLaunchers(input)]);
|
||||
expect(first).toEqual(second);
|
||||
const command = await fixture.runner.execute({ command: "bash", args: ["--noprofile", "--norc", "-c", "git; gh"], env: second });
|
||||
expect(command.exitCode, command.stderr).toBe(0);
|
||||
expect(command.stdout).toBe("git started\ngh started\n");
|
||||
expect(await readFile(path.join(first.PAPERCLIP_GITHUB_LAUNCHER_DIR, "package.json"), "utf8")).toBe('{"type":"commonjs"}\n');
|
||||
});
|
||||
|
||||
it.each([undefined, false])("keeps providers without a concurrent capability serial (%s)", async (concurrent) => {
|
||||
const fixture = await sandbox("usr/bin", concurrent);
|
||||
const execute = fixture.runner.execute.getMockImplementation()!;
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
fixture.runner.execute.mockImplementation(async (input) => {
|
||||
active += 1;
|
||||
maximum = Math.max(maximum, active);
|
||||
try { return await execute(input); } finally { active -= 1; }
|
||||
});
|
||||
await prepareGitHubOperationLaunchers({ runId: "serial-provider", target: fixture.target,
|
||||
cwd: fixture.root, env: { PATH: fixture.remotePath } });
|
||||
expect(maximum).toBe(1);
|
||||
expect(active).toBe(0);
|
||||
expect(fixture.runner.execute).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
// The Daytona native directory oracle uses Linux inotify. Run its real
|
||||
// filesystem regression on that platform; shell/launcher tests cover both.
|
||||
it.runIf(process.platform === "linux")("keeps launcher restaging and command scratch inside the runner runtime", async () => {
|
||||
|
||||
Reference in new issue
Block a user