mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Keep computer process capabilities stable across warm admission
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
60b3d32722
commit
52aee03920
7 files changed
+59
-15
No files matched your search
@@ -0,0 +1,9 @@
|
||||
import type { AdapterCommandBackedExecutionTarget, AdapterExecutionTarget } from "./execution-target.js";
|
||||
|
||||
/** Command transport capability; sandbox allocation ownership is unchanged. */
|
||||
export function adapterExecutionTargetIsCommandBacked(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
): target is AdapterCommandBackedExecutionTarget {
|
||||
return target?.kind === "remote" && (target.transport === "sandbox" || target.transport === "computer");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js";
|
||||
export { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js";
|
||||
import fs from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
@@ -237,6 +239,8 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor
|
||||
export interface AdapterComputerExecutionTarget extends Omit<AdapterSandboxExecutionTarget, "transport" | "sandboxLeaseAcquisition"> {
|
||||
transport: "computer";
|
||||
listenerPort: number;
|
||||
/** Native process control survives attempt handoff only for the same process claim. */
|
||||
processRunner: CommandManagedRuntimeRunner;
|
||||
resourceAuthority: { kind: "computer-owner"; computerId: string; ownerId: string; generation: number };
|
||||
fileAuthority: { kind: "remote-persistent"; placementId: string; root: string; agentHome: string };
|
||||
launch(input: { command: string; args?: string[]; cwd?: string; env?: Record<string, string> }): Promise<unknown>;
|
||||
@@ -248,12 +252,6 @@ export interface AdapterComputerExecutionTarget extends Omit<AdapterSandboxExecu
|
||||
|
||||
export type AdapterCommandBackedExecutionTarget = AdapterSandboxExecutionTarget | AdapterComputerExecutionTarget;
|
||||
|
||||
/** Command transport capability; sandbox allocation ownership is unchanged. */
|
||||
export function adapterExecutionTargetIsCommandBacked(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
): target is AdapterCommandBackedExecutionTarget {
|
||||
return target?.kind === "remote" && (target.transport === "sandbox" || target.transport === "computer");
|
||||
}
|
||||
|
||||
export type AdapterExecutionTarget =
|
||||
| AdapterLocalExecutionTarget
|
||||
|
||||
@@ -218,6 +218,7 @@ it("uses each computer owner's allocated port without changing the sandbox defau
|
||||
const base = {
|
||||
kind: "remote" as const, transport: "computer" as const, providerKey: "boat", remoteCwd: "/home/user/project",
|
||||
leaseId: "attempt-lease", listenerPort: 45101, effectiveCapabilities: capabilities, getRunnerIngressEndpoint,
|
||||
processRunner: { execute: async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString() }) },
|
||||
resourceAuthority: { kind: "computer-owner" as const, computerId: "machine", ownerId: "owner-a", generation: 2 },
|
||||
fileAuthority: { kind: "remote-persistent" as const, placementId: "placement", root: "/home/user/project", agentHome: "/home/user/agent-a" },
|
||||
launch: async () => ({}), inspectProcess: async () => ({ running: true, claim: {} }),
|
||||
@@ -230,7 +231,7 @@ it("uses each computer owner's allocated port without changing the sandbox defau
|
||||
expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: port });
|
||||
expect(getRunnerIngressEndpoint).toHaveBeenLastCalledWith({ leaseId: "attempt-lease", port, path: "/api/runner/v1/connect/run-1" });
|
||||
}
|
||||
const { listenerPort, resourceAuthority, fileAuthority, launch, inspectProcess, retainWarm, retire, computerTool, ...common } = base;
|
||||
const { listenerPort, processRunner, resourceAuthority, fileAuthority, launch, inspectProcess, retainWarm, retire, computerTool, ...common } = base;
|
||||
const resolved = await resolvePaperclipRunnerTransport({ target: { ...common, transport: "sandbox" }, runId: "sandbox-run",
|
||||
localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true });
|
||||
expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: 43127 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { adapterExecutionTargetIsCommandBacked, type AdapterExecutionTarget } from "./execution-target.js";
|
||||
import type { AdapterExecutionTarget } from "./execution-target.js";
|
||||
import { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js";
|
||||
|
||||
export const PAPERCLIP_RUNNER_INGRESS_PORT = 43_127;
|
||||
export const PAPERCLIP_RUNNER_CONNECT_PATH_PREFIX = "/api/runner/v1/connect";
|
||||
|
||||
@@ -76,10 +76,12 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive
|
||||
return { providerLeaseId: lease.providerLeaseId, state: "stopped" };
|
||||
},
|
||||
async realizeWorkspace({ lease, workspace }) {
|
||||
const request = workspace.metadata?.workspaceRealizationRequest as { source?: { projectId?: string; repoUrl?: string; branchName?: string; strategy?: string }; issueId?: string } | undefined;
|
||||
const request = workspace.metadata?.workspaceRealizationRequest as { source?: { projectId?: string; repoUrl?: string; repoRef?: string; branchName?: string; strategy?: string }; issueId?: string } | undefined;
|
||||
const result = await computers.realizeWorkspace({ ...scope(lease), owner: computerOwnerFromLease(lease),
|
||||
projectId: request?.source?.projectId ?? undefined, taskId: request?.issueId ?? undefined,
|
||||
repositoryUrl: request?.source?.repoUrl ?? undefined, branch: request?.source?.branchName ?? undefined,
|
||||
repositoryUrl: request?.source?.repoUrl ?? undefined,
|
||||
branch: request?.source?.branchName ?? request?.source?.repoRef ?? undefined,
|
||||
baseRef: request?.source?.repoRef ?? undefined,
|
||||
mode: request?.source?.strategy === "git_worktree" ? "worktree" : "shared" });
|
||||
await leases.updateLeaseMetadata(lease.id, { ...lease.metadata, remoteCwd: result.remoteCwd,
|
||||
fileAuthority: { kind: "remote-persistent", placementId: result.placementId, root: result.remoteCwd, agentHome: result.agentHome } });
|
||||
@@ -88,7 +90,8 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive
|
||||
},
|
||||
async execute({ lease, command, args, cwd, env, stdin, timeoutMs, onLog }) {
|
||||
const binding = await recover(lease);
|
||||
return binding.runner.execute({ command, args, cwd: cwd ?? binding.remoteCwd, env, stdin, timeoutMs, onLog });
|
||||
return binding.runner.execute({ command, args, cwd: cwd ?? binding.remoteCwd, env, stdin, timeoutMs,
|
||||
onLog: onLog ? async (stream, chunk) => { await onLog(stream, chunk); } : undefined });
|
||||
},
|
||||
async resolveCapabilities() { return COMPUTER_CAPABILITIES; },
|
||||
};
|
||||
@@ -101,13 +104,13 @@ export async function computerExecutionTarget(db: Db, lease: EnvironmentLease, i
|
||||
const owner = computerOwnerFromLease(lease);
|
||||
const binding = await computers.recover({ ...scope, owner });
|
||||
const ingress = async (path: string): Promise<RunnerIngressEndpoint> => {
|
||||
const endpoint = await binding.ingress({ path });
|
||||
const endpoint = await binding.process.ingress({ path });
|
||||
return { kind: "authenticated_websocket", websocketUrl: endpoint.url,
|
||||
secretHeaders: Object.entries(endpoint.secretHeaders).map(([name, value]) => ({ name, value })),
|
||||
generation: `${owner.ownerId}:${owner.generation}`, refresh: () => ingress(path), close: async () => {} };
|
||||
};
|
||||
return { kind: "remote", transport: "computer", providerKey: "boat", environmentId: lease.environmentId, leaseId: lease.id,
|
||||
remoteCwd: binding.remoteCwd, listenerPort: binding.listenerPort, runner: binding.runner, shellCommand: "bash",
|
||||
remoteCwd: binding.remoteCwd, listenerPort: binding.listenerPort, runner: binding.runner, processRunner: binding.process.runner, shellCommand: "bash",
|
||||
effectiveCapabilities: COMPUTER_CAPABILITIES, reusableLeaseConfigured: true,
|
||||
runnerLifecyclePolicy: { mode: "warm", idleTimeoutMs },
|
||||
resourceAuthority: { kind: "computer-owner", ...owner },
|
||||
|
||||
@@ -705,6 +705,29 @@ describe("remote runner launch fingerprint compatibility", () => {
|
||||
});
|
||||
|
||||
describe("remote runner process supervision", () => {
|
||||
it("launches a computer runner through owned process admission, never detached shell allocation", async () => {
|
||||
let nonce = "";
|
||||
const launch = vi.fn(async (request: { args: string[] }) => { nonce = request.args[4]!; return {}; });
|
||||
const execute = vi.fn(async (request: { args: string[] }) => {
|
||||
const label = request.args[2];
|
||||
return { exitCode: label === "paperclip-runner-monitor" ? 3 : 0, timedOut: false, signal: null,
|
||||
stdout: label === "paperclip-runner-process-identity" ? `${nonce}\n4321\n2026-09-06T00:00:00.000Z\ncomputer-runner\nlinux:abcd-1234:100\n`
|
||||
: label === "paperclip-runner-diagnostics" ? "stopped" : Buffer.from('{}').toString('base64'), stderr: "" };
|
||||
});
|
||||
const launcher = createRemoteRunnerProcessLauncher({
|
||||
target: { kind: "remote", transport: "computer", remoteCwd: "/home/user/project", launch } as never,
|
||||
runner: { execute } as never, remoteBinary: "/runtime/runnerd", processIdentityPath: "/runtime/state/identity",
|
||||
stateDirectory: "/runtime/state", diagnosticsDirectory: "/runtime/logs", runnerInstanceId: "computer-runner",
|
||||
});
|
||||
const handle = launcher({ command: "/controller/runnerd", args: ["--runner-id", "computer-runner"], cwd: "/controller", environment: {} });
|
||||
await handle.completion;
|
||||
expect(launch).toHaveBeenCalledOnce();
|
||||
expect(launch.mock.calls[0]![0].args).toContain("/runtime/runnerd");
|
||||
expect(launch.mock.calls[0]![0].args[1]).not.toContain("nohup");
|
||||
expect(execute.mock.calls.every(([request]) => request.args[2] !== "paperclip-runner-launch")).toBe(true);
|
||||
expect(handle.child.pid).toBe(4321);
|
||||
});
|
||||
|
||||
it.each(["delivered", "sandbox_missing", "logging_failed"] as const)(
|
||||
"detaches runnerd and contains asynchronous signal failures (%s)", async (signalOutcome) => {
|
||||
let launchNonce = "";
|
||||
|
||||
@@ -554,7 +554,7 @@ export async function claimWarmNativeInstructionCopy(input: {
|
||||
function instructionTargetIdentity(target?: AdapterExecutionTarget | null): string {
|
||||
return JSON.stringify(target?.kind === "remote" ? {
|
||||
environmentId: target.environmentId, cwd: target.remoteCwd,
|
||||
providerLeaseId: target.transport === "sandbox" ? target.sandboxLeaseAcquisition?.providerLeaseId : target.transport === "computer" ? target.resourceAuthority.computerId : target.spec,
|
||||
providerLeaseId: target.transport === "sandbox" ? target.sandboxLeaseAcquisition?.providerLeaseId : target.transport === "computer" ? [target.resourceAuthority.computerId, target.resourceAuthority.ownerId] : target.spec,
|
||||
} : { kind: "local", environmentId: target?.environmentId });
|
||||
}
|
||||
|
||||
@@ -567,6 +567,12 @@ export async function reserveWarmNativeInstructionDirectory(input: {
|
||||
for (const [id, entry] of warmNativeSessions) {
|
||||
if (entry.companyId !== input.companyId || entry.agentId !== input.agentId || entry.instructionCopy?.runId !== input.previousRunId) continue;
|
||||
if (entry.busy || entry.preparingRunId) throw new Error("native_session_supervisor_busy");
|
||||
if (input.target?.kind === "remote" && input.target.transport === "computer" &&
|
||||
entry.computerTarget?.resourceAuthority.computerId === input.target.resourceAuthority.computerId &&
|
||||
entry.computerTarget.resourceAuthority.ownerId === input.target.resourceAuthority.ownerId &&
|
||||
entry.computerTarget.resourceAuthority.generation < input.target.resourceAuthority.generation) {
|
||||
entry.computerTarget = input.target;
|
||||
}
|
||||
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
||||
entry.idleTimer = null;
|
||||
entry.preparingRunId = input.runId;
|
||||
@@ -8463,6 +8469,9 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
entry.credentialRunId !== input.execution.binding.runId);
|
||||
if (
|
||||
input.refreshTools === true ||
|
||||
(input.runnerExecutionTarget?.kind === "remote" && input.runnerExecutionTarget.transport === "computer" &&
|
||||
(entry.computerTarget?.resourceAuthority.computerId !== input.runnerExecutionTarget.resourceAuthority.computerId ||
|
||||
entry.computerTarget?.resourceAuthority.ownerId !== input.runnerExecutionTarget.resourceAuthority.ownerId)) ||
|
||||
entry.closeOnReleaseReason !== undefined ||
|
||||
entry.configDigest !== warmConfigDigest ||
|
||||
entry.configuredEnvironmentDigest !== configuredEnvironmentDigest ||
|
||||
@@ -11386,7 +11395,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
spec: remoteTarget.spec,
|
||||
defaultCwd: remoteTarget.remoteCwd,
|
||||
})
|
||||
: remoteTarget.runner
|
||||
: remoteTarget.transport === "computer" ? remoteTarget.processRunner : remoteTarget.runner
|
||||
: null;
|
||||
if (remoteTarget && !remoteCommandRunner) {
|
||||
throw new Error(
|
||||
|
||||
Reference in new issue
Block a user