mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - One server process prepares native runs, renews every controller lease on timers, and serves every request > - On a run path, the server hashed the 31 MB runner binary with `readFileSync` (more than once per run), hashed provider packs and harness backups synchronously, and ran `tar` with `execFileSync` (timeouts up to 60 s, one with no timeout) > - On a slow disk, each of these calls freezes the whole process. A 103 s stall on a hosted instance lapsed every lease. The last event before that stall was immediately before a synchronous read and hash of the runner binary (see #20694 and #20709) > - This pull request moves those reads, hashes and archive commands off the event loop. It computes the runner binary digest once per boot > - The benefit is that a slow disk delays only the run that waits for it. Turns also start faster, because the runner binary is no longer re-read for every run ## Linked Issues or Issue Description Refs #20694, #20709 **What happened?** On a hosted instance, the server process stopped for 103 s without a restart. Every controller lease lapsed, and two tasks needed a manual Retry. The last run event before the gap was immediately before `matchesControllerRunnerArtifact`, which does `createHash("sha256").update(readFileSync(controllerRunnerBinary))`. Four workspace restores were running at the same time, and file I/O was very slow (a 43 s archive hash, 20 s snapshot decrypts). The trigger is not proven. But each synchronous call below blocks the event loop for as long as the disk is slow. **Expected behavior** File hashing and archive commands on a run path must not block the event loop. A slow disk must delay only the run that waits for it. **Steps to reproduce** 1. Make a remote runner binary slow to read (the test uses a FIFO whose writer waits), or make `tar` slow (the test puts a `tar` wrapper that sleeps first on `PATH`). 2. Call `resolveRemotePaperclipRunnerBinary`, `stageRemoteRunnerDirectory`, or `syncRemoteRunnerDirectoryOut` while a 10 ms timer runs. 3. Before this change, the timer stops for the whole slow step (814 ms, 1,764 ms and 3,379 ms). After this change, it keeps firing. ## What Changed - `server/src/services/native-runtime/off-loop-file-work.ts` (new): - `sha256FileStreamed` - `cachedFileSha256`: a streamed digest, reused while the file's device, inode, size, mtime and ctime do not change - `binaryTargetFromHeader`: reads 64 KiB; reads the rest of the file only when the header cannot answer, so the result equals `runnerBinaryTarget(readFileSync(path))` - `sha256DirectoryTreeStreamed` - `execFileBuffered`: `execFile` with stdin input, `maxBuffer`, `timeout` and `env`. It rejects on a non-zero exit, a timeout or a `maxBuffer` overflow, like `execFileSync`. - `native-session-executor.ts`: - `matchesControllerRunnerArtifact` uses `cachedFileSha256`. Each boot reads the binary once, not once or twice for every run. - The remote Claude daemon target is read from the binary header. - The bundled remote runner binary is checked by `verifiedBundledRemoteRunnerBinary`: the same checks and errors, with a cached digest. - Provider-pack upload verification uses the new `verifyRemoteProviderPackManifest`. It runs the same checks, in the same order, with the same errors. One generator drives both the sync and the streamed readers. - Harness backup build and verify on run paths use streamed digests (`buildNativeHarnessBackupManifestStreamed`, `verifyNativeHarnessBackupStreamed`). The digest format does not change. - `stageRemoteRunnerDirectory` (3 calls), `assertSafeRemoteCheckpointArchive` (3 calls) and the checkpoint extract use `execFileBuffered`, with the same arguments, timeouts and buffer limits. `stageRemoteRunnerFile` uses `readFile`. - `packages/paperclip-runner/src/live/runner-binary.ts`: the default daemon lookup (`resolvePackagedRunnerBinary`, used by the local and remote resolvers) classifies the daemon with `runnerBinaryFileTarget`. It reads the 64 KiB header, not the whole file. - `packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts`: the release-manifest checks now live in one helper, `bundledRemoteRunnerRelease`. The sync `bundledRemoteRunnerBinary` and the server's streamed check both use it. - `native-codex-runner.ts`: - `resolveRemotePaperclipRunnerBinary` reads only the header. - The local `runnerDigest` uses `cachedFileSha256`. - New `verifiedBundledRemoteRunnerBinary`. **Ordering and atomicity.** I checked each converted call for code that relied on the sync call being atomic with respect to other runs: - **Checkpoint copy-back.** It extracts into a private `mkdtemp` directory. The replace sequence (`existsSync`, `rename`, `rename`, `rm`) stays synchronous, so no other run can see a half-installed checkpoint. This function already awaited a remote command before the extract, so this change adds no new interleaving with the target. - **Harness backup verify then restore.** The verify was sync, but the restore after it was already async. The window between them existed before. One owner serializes the turns of a session, so no other turn publishes a backup for that session while a turn verifies it. - **Staging and provider-pack hashing.** These read build-owned, immutable trees, or the private staging copies of a run. ## Verification - Red/green: `pnpm --filter @paperclipai/server exec vitest run src/services/native-runtime/off-loop-file-work.test.ts` - With `native-session-executor.ts` and `native-codex-runner.ts` from `origin/master`: 3 failed. The longest timer gaps were 814 ms (runner binary target read), 1,764 ms (directory staging tar) and 3,379 ms (checkpoint list, size check and extract). - With this change: 6 passed. The gaps stay under 400 ms. The same file also checks the digest cache, the header fallback, and the `execFileSync`-like errors (exit code, timeout kill, `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`). - `native-session-executor.test.ts`: - The streamed provider-pack verifier returns the same manifest as the sync reader, and rejects a changed asset tree with the same error. - The streamed harness backup manifest equals the sync one. - The streamed verify agrees with the sync verify, for a valid backup and for a corrupt one. - Run together: `native-session-executor`, `off-loop-file-work`, `native-codex-runner`, `setup-readiness`, `antigravity-connection-probe` (1,653 passed). `run-completion.test.ts` passed all 153 tests. Its `afterAll` embedded-Postgres cleanup timed out locally, which is not related to this change; CI runs it. - `pnpm --filter @paperclipai/server exec tsc --noEmit` passes. ## Risks - **Turn start.** This change adds no wait before `turn.start`; it removes work. The first run of a boot streams the runner binary once. Later runs reuse the digest after one `stat`. - **Cache staleness.** The cache keys on device, inode, size, mtime and ctime. An in-place rewrite changes mtime and ctime, and a replaced file changes its inode, so a changed binary is hashed again. - **stderr.** `execFileSync` wrote a child's stderr to the server's stderr. `execFileBuffered` captures it. Tar warnings no longer appear in server logs; errors and exit codes are unchanged. - **Persisted state.** No persisted shape changes. Old saved state and new code (and the reverse) read the same manifests and digests. - **Not converted in this PR:** small metadata syscalls (`existsSync`, `lstatSync`, `readdirSync`, small JSON reads and writes, renames) in the native state machines. Some of these rely on running synchronously. Also not converted: the sync harness backup verify inside the sync restart-authority checks, and the sync stamp verify in `native-harness-backup-storage.ts`. Both are listed for a follow-up. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude Code with tool use, shell, and code execution. ## 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 - [x] I have updated relevant documentation to reflect my changes (code comments carry the contract) - [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 - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
629 lines
22 KiB
TypeScript
629 lines
22 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { accessSync, chmodSync, constants, mkdirSync } from "node:fs";
|
|
import { access } from "node:fs/promises";
|
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { and, eq } from "drizzle-orm";
|
|
|
|
import type { AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
|
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agentSessionGoalActions, agentTaskSessions } from "@paperclipai/db";
|
|
|
|
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
|
import { failRunnerGoalAction } from "../runner-goals.js";
|
|
import {
|
|
BUNDLED_REMOTE_RUNNER_IDENTITY_MISMATCH,
|
|
bundledRemoteRunnerRelease,
|
|
createPaperclipRunnerAuthorizedToolSet,
|
|
defaultCapabilityRunnerdBinary,
|
|
defaultCapabilityRunnerdBinaryAsync,
|
|
runnerBinaryTarget,
|
|
type PaperclipSemanticToolDefinition,
|
|
} from "../../vendor/paperclip-runner/index.js";
|
|
import { runnerPrpCoordinator } from "./runner-prp-coordinator.js";
|
|
import { binaryTargetFromHeader, cachedFileSha256 } from "./off-loop-file-work.js";
|
|
|
|
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
const RUNNER_VERSION = "paperclip-runner-v1";
|
|
|
|
interface NativeGoalControl {
|
|
sessionId: string;
|
|
requestId: string;
|
|
action: string;
|
|
payload: Record<string, unknown>;
|
|
currentStatus: string | null;
|
|
workingNow: boolean;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
async function readNativeGoalControl(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
issueId: string;
|
|
agentId: string;
|
|
requestId: string | null;
|
|
}): Promise<NativeGoalControl | null> {
|
|
if (!input.requestId) return null;
|
|
const [row] = await input.db
|
|
.select({
|
|
sessionId: agentTaskSessions.id,
|
|
requestId: agentSessionGoalActions.requestId,
|
|
action: agentSessionGoalActions.action,
|
|
payload: agentSessionGoalActions.payloadJson,
|
|
goal: agentTaskSessions.goalJson,
|
|
goalStatus: agentTaskSessions.goalStatus,
|
|
})
|
|
.from(agentSessionGoalActions)
|
|
.innerJoin(
|
|
agentTaskSessions,
|
|
eq(agentTaskSessions.id, agentSessionGoalActions.sessionId),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(agentSessionGoalActions.companyId, input.companyId),
|
|
eq(agentSessionGoalActions.requestId, input.requestId),
|
|
eq(agentTaskSessions.agentId, input.agentId),
|
|
eq(agentTaskSessions.taskKey, input.issueId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (!row) return null;
|
|
const goal = asRecord(row.goal);
|
|
return {
|
|
sessionId: row.sessionId,
|
|
requestId: row.requestId,
|
|
action: row.action,
|
|
payload: asRecord(row.payload),
|
|
currentStatus: row.goalStatus,
|
|
workingNow: goal.workingNow === true,
|
|
};
|
|
}
|
|
|
|
function nativeGoalCommands(control: NativeGoalControl): Array<{
|
|
type: string;
|
|
payload: Record<string, unknown>;
|
|
}> {
|
|
if (control.action === "clear") {
|
|
return [
|
|
{
|
|
type: "session.goal.clear",
|
|
payload: { requestId: control.requestId },
|
|
},
|
|
];
|
|
}
|
|
if (control.action === "pause") {
|
|
return [
|
|
{
|
|
type: "session.goal.set",
|
|
payload: { requestId: control.requestId, status: "paused" },
|
|
},
|
|
];
|
|
}
|
|
if (control.action === "resume") {
|
|
return [
|
|
{
|
|
type: "session.goal.set",
|
|
payload: { requestId: control.requestId, status: "active" },
|
|
},
|
|
];
|
|
}
|
|
const payload = {
|
|
requestId: control.requestId,
|
|
objective: control.payload.objective,
|
|
...(control.action === "edit" ? {} : { status: "active" }),
|
|
...(Object.prototype.hasOwnProperty.call(control.payload, "tokenBudget")
|
|
? { tokenBudget: control.payload.tokenBudget }
|
|
: {}),
|
|
};
|
|
return control.action === "replace"
|
|
? [
|
|
{
|
|
type: "session.goal.clear",
|
|
payload: { requestId: control.requestId },
|
|
},
|
|
{ type: "session.goal.set", payload },
|
|
]
|
|
: [{ type: "session.goal.set", payload }];
|
|
}
|
|
|
|
interface NativeRunnerProviderLaunch {
|
|
readonly command: string;
|
|
readonly args: string[];
|
|
readonly providerVersion?: string;
|
|
}
|
|
|
|
interface NativeRunnerPrepareInput {
|
|
readonly cwd: string;
|
|
readonly model: string | null;
|
|
readonly resumeProviderSessionId: string | null;
|
|
readonly completionContract: { revision: string; criterionIds: string[] };
|
|
readonly semanticTools: readonly PaperclipSemanticToolDefinition[];
|
|
readonly providerLaunch?: NativeRunnerProviderLaunch;
|
|
}
|
|
|
|
export function buildNativeRunnerPreparePayload(
|
|
input: NativeRunnerPrepareInput,
|
|
): Record<string, unknown> {
|
|
return {
|
|
provider: {
|
|
kind: "codex",
|
|
provider: "codex",
|
|
driver: "codex_app_server",
|
|
providerVersion: input.providerLaunch?.providerVersion ?? "codex-app-server-v1",
|
|
command: input.providerLaunch?.command ?? "codex",
|
|
args: input.providerLaunch?.args ?? ["app-server"],
|
|
cwd: input.cwd,
|
|
...(input.model ? { model: input.model } : {}),
|
|
...(input.resumeProviderSessionId
|
|
? { providerSessionId: input.resumeProviderSessionId }
|
|
: {}),
|
|
instructions: "",
|
|
approvalPolicy: "never",
|
|
},
|
|
completionContract: input.completionContract,
|
|
authorizedTools: createPaperclipRunnerAuthorizedToolSet(input.semanticTools),
|
|
};
|
|
}
|
|
|
|
function executableName(): string {
|
|
return process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
|
|
}
|
|
|
|
function controllerPaperclipRunnerBinaryConfiguration(): string | undefined {
|
|
return process.env.PAPERCLIP_RUNNER_BINARY;
|
|
}
|
|
|
|
export function assertPaperclipRunnerBinaryConfiguration(
|
|
configuredPath = controllerPaperclipRunnerBinaryConfiguration(),
|
|
): void {
|
|
if (configuredPath && !isAbsolute(configuredPath)) {
|
|
throw new Error("PAPERCLIP_RUNNER_BINARY must be an absolute path");
|
|
}
|
|
}
|
|
|
|
export function resolvePaperclipRunnerBinary(
|
|
configuredPath = controllerPaperclipRunnerBinaryConfiguration(),
|
|
): string {
|
|
assertPaperclipRunnerBinaryConfiguration(configuredPath);
|
|
if (configuredPath) {
|
|
try {
|
|
accessSync(configuredPath, constants.R_OK | (process.platform === "win32" ? 0 : constants.X_OK));
|
|
return configuredPath;
|
|
} catch {
|
|
// Preserve the existing fallback for an unavailable configured path.
|
|
}
|
|
}
|
|
const candidates = [
|
|
// The runner owns its compiled/vendored root and verifies the target header.
|
|
// Never prefer a flat build-host executable over a staged release target.
|
|
defaultCapabilityRunnerdBinary(),
|
|
resolve(
|
|
moduleDirectory,
|
|
"../../../../packages/paperclip-runner/runner/target/release",
|
|
executableName(),
|
|
),
|
|
].filter((candidate): candidate is string => Boolean(candidate));
|
|
for (const candidate of candidates) {
|
|
try {
|
|
accessSync(candidate, constants.R_OK | (process.platform === "win32" ? 0 : constants.X_OK));
|
|
return candidate;
|
|
} catch {
|
|
// Continue through the fixed production and workspace locations.
|
|
}
|
|
}
|
|
throw new Error(
|
|
"paperclip_runner_binary_missing: build @paperclipai/paperclip-runner or set PAPERCLIP_RUNNER_BINARY",
|
|
);
|
|
}
|
|
|
|
/** `resolvePaperclipRunnerBinary` without blocking the event loop (run paths). */
|
|
export async function resolvePaperclipRunnerBinaryAsync(
|
|
configuredPath = controllerPaperclipRunnerBinaryConfiguration(),
|
|
): Promise<string> {
|
|
assertPaperclipRunnerBinaryConfiguration(configuredPath);
|
|
if (configuredPath) {
|
|
try {
|
|
await access(configuredPath, constants.R_OK | (process.platform === "win32" ? 0 : constants.X_OK));
|
|
return configuredPath;
|
|
} catch {
|
|
// Preserve the existing fallback for an unavailable configured path.
|
|
}
|
|
}
|
|
const candidates = [
|
|
// The runner owns its compiled/vendored root and verifies the target header.
|
|
// Never prefer a flat build-host executable over a staged release target.
|
|
await defaultCapabilityRunnerdBinaryAsync(),
|
|
resolve(
|
|
moduleDirectory,
|
|
"../../../../packages/paperclip-runner/runner/target/release",
|
|
executableName(),
|
|
),
|
|
].filter((candidate): candidate is string => Boolean(candidate));
|
|
for (const candidate of candidates) {
|
|
try {
|
|
await access(candidate, constants.R_OK | (process.platform === "win32" ? 0 : constants.X_OK));
|
|
return candidate;
|
|
} catch {
|
|
// Continue through the fixed production and workspace locations.
|
|
}
|
|
}
|
|
throw new Error(
|
|
"paperclip_runner_binary_missing: build @paperclipai/paperclip-runner or set PAPERCLIP_RUNNER_BINARY",
|
|
);
|
|
}
|
|
|
|
export async function resolveRemotePaperclipRunnerBinary(input: {
|
|
runner: CommandManagedRuntimeRunner;
|
|
remoteCwd: string;
|
|
configuredPath?: string | null;
|
|
}): Promise<string> {
|
|
const probe = await input.runner.execute({ command: "sh", args: ["-c", "uname -s; uname -m"],
|
|
cwd: input.remoteCwd, bypassSession: true, timeoutMs: 10_000 });
|
|
const [os, arch] = probe.stdout.trim().split(/\r?\n/);
|
|
const platform = os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : null;
|
|
const architecture = arch === "x86_64" ? "x64" : arch === "aarch64" || arch === "arm64" ? "arm64" : null;
|
|
if (probe.exitCode !== 0 || probe.timedOut || !platform || !architecture) {
|
|
throw new Error("runner_remote_artifact_platform_mismatch: could not identify a supported remote OS and architecture; check the selected execution environment and retry");
|
|
}
|
|
const configured = input.configuredPath?.trim();
|
|
if (configured && !isAbsolute(configured)) throw new Error("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH must be an absolute path");
|
|
const controllerOverride = platform === process.platform && architecture === process.arch
|
|
? process.env.PAPERCLIP_RUNNER_BINARY : undefined;
|
|
const binary = configured || (controllerOverride
|
|
? await resolvePaperclipRunnerBinaryAsync(controllerOverride)
|
|
: await defaultCapabilityRunnerdBinaryAsync(platform, architecture));
|
|
// The header names the target; reading the whole daemon here blocked the
|
|
// event loop on every remote run.
|
|
let target: string | null;
|
|
try { target = await binaryTargetFromHeader(binary, runnerBinaryTarget); }
|
|
catch (cause) { throw new Error(`runner_remote_artifact_unavailable: install the Paperclip release containing the ${platform}-${architecture} daemon or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`, { cause }); }
|
|
if (target !== `${platform}-${architecture}`) {
|
|
throw new Error(`runner_remote_artifact_platform_mismatch: the selected daemon does not match ${platform}-${architecture}; install the matching Paperclip release or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`);
|
|
}
|
|
return binary;
|
|
}
|
|
|
|
/**
|
|
* `bundledRemoteRunnerBinary()` without blocking the event loop: the same
|
|
* release-manifest, target and digest checks and errors, with the digest
|
|
* streamed once per boot (it is cached while the file is unchanged).
|
|
*/
|
|
export async function verifiedBundledRemoteRunnerBinary(
|
|
target: "linux-x64" | "linux-arm64" = "linux-x64",
|
|
): Promise<string> {
|
|
const { binary, sha256 } = bundledRemoteRunnerRelease(undefined, target);
|
|
if (await binaryTargetFromHeader(binary, runnerBinaryTarget) !== target || await cachedFileSha256(binary) !== sha256) {
|
|
throw new Error(BUNDLED_REMOTE_RUNNER_IDENTITY_MISMATCH);
|
|
}
|
|
return binary;
|
|
}
|
|
|
|
export function buildNativeRunnerArguments(input: {
|
|
connectUrl: string;
|
|
stateDirectory: string;
|
|
runnerInstanceId: string;
|
|
environmentLeaseId: string;
|
|
runId: string;
|
|
normalizedSessionId: string;
|
|
turnId: string;
|
|
itemId: string;
|
|
runnerDigest: string;
|
|
maxRuntimeMs: number;
|
|
}): string[] {
|
|
return [
|
|
"--connect-url", input.connectUrl,
|
|
"--state-dir", input.stateDirectory,
|
|
"--runner-id", input.runnerInstanceId,
|
|
"--environment-lease-id", input.environmentLeaseId,
|
|
"--run-id", input.runId,
|
|
"--session-id", input.normalizedSessionId,
|
|
"--turn-id", input.turnId,
|
|
"--item-id", input.itemId,
|
|
"--runner-version", RUNNER_VERSION,
|
|
"--runner-digest", input.runnerDigest,
|
|
"--max-runtime-ms", String(input.maxRuntimeMs),
|
|
];
|
|
}
|
|
|
|
function privateDirectory(path: string): void {
|
|
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
if (process.platform !== "win32") chmodSync(path, 0o700);
|
|
}
|
|
|
|
function waitForExit(child: ChildProcess): Promise<{
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
}> {
|
|
return new Promise((resolveExit, rejectExit) => {
|
|
child.once("error", rejectExit);
|
|
child.once("exit", (code, signal) => resolveExit({ code, signal }));
|
|
});
|
|
}
|
|
|
|
async function waitForChildExit(exit: Promise<unknown>, timeoutMs: number): Promise<boolean> {
|
|
let timer: NodeJS.Timeout | null = null;
|
|
try {
|
|
return await Promise.race([
|
|
exit.then(() => true),
|
|
new Promise<false>((resolveTimeout) => {
|
|
timer = setTimeout(() => resolveTimeout(false), timeoutMs);
|
|
timer.unref();
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function signalRunnerProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void {
|
|
if (process.platform !== "win32" && child.pid) {
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
return;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
|
|
}
|
|
}
|
|
child.kill(signal);
|
|
}
|
|
|
|
async function stopChild(
|
|
child: ChildProcess,
|
|
exit: Promise<unknown>,
|
|
allowGracefulExit = false,
|
|
): Promise<void> {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
if (allowGracefulExit && await waitForChildExit(exit, 5_000)) return;
|
|
signalRunnerProcessGroup(child, "SIGTERM");
|
|
if (await waitForChildExit(exit, 5_000)) return;
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
signalRunnerProcessGroup(child, "SIGKILL");
|
|
}
|
|
}
|
|
|
|
export async function executeNativeCodexRunner(input: {
|
|
db: Db;
|
|
companyId: string;
|
|
issueId: string;
|
|
runId: string;
|
|
agentId: string;
|
|
runnerInstanceId: string;
|
|
environmentLeaseId: string;
|
|
normalizedSessionId: string;
|
|
turnId: string;
|
|
itemId: string;
|
|
cwd: string;
|
|
prompt: string;
|
|
model: string | null;
|
|
resumeProviderSessionId: string | null;
|
|
goalControlRequestId?: string | null;
|
|
resumeSessionGoalHeartbeat?: boolean;
|
|
completionContract: { revision: string; criterionIds: string[] };
|
|
timeoutMs: number;
|
|
environment: Record<string, string>;
|
|
/** Internal test seam; production always resolves the packaged binary. */
|
|
runnerBinary?: string;
|
|
/** Internal test seam; production always uses the instance runtime root. */
|
|
runtimeRoot?: string;
|
|
/** Internal conformance seam; production always launches `codex app-server`. */
|
|
providerLaunch?: NativeRunnerProviderLaunch;
|
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
onSpawn: (meta: {
|
|
pid: number;
|
|
processGroupId: number | null;
|
|
startedAt: string;
|
|
}) => Promise<void>;
|
|
}): Promise<AdapterExecutionResult> {
|
|
const binary = input.runnerBinary ?? await resolvePaperclipRunnerBinaryAsync();
|
|
const runnerDigest = await cachedFileSha256(binary);
|
|
const runtimeRoot = input.runtimeRoot
|
|
? resolve(input.runtimeRoot)
|
|
: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner");
|
|
const runnerStateDirectory = resolve(runtimeRoot, "runner", input.runId);
|
|
const goalControl = await readNativeGoalControl({
|
|
db: input.db,
|
|
companyId: input.companyId,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
requestId: input.goalControlRequestId ?? null,
|
|
});
|
|
privateDirectory(runtimeRoot);
|
|
privateDirectory(resolve(runtimeRoot, "control-plane"));
|
|
privateDirectory(resolve(runtimeRoot, "runner"));
|
|
privateDirectory(runnerStateDirectory);
|
|
|
|
const prepared = await runnerPrpCoordinator(input.db, {
|
|
stateRoot: resolve(runtimeRoot, "control-plane"),
|
|
}).prepare({
|
|
companyId: input.companyId,
|
|
issueId: input.issueId,
|
|
runId: input.runId,
|
|
agentId: input.agentId,
|
|
runnerInstanceId: input.runnerInstanceId,
|
|
environmentLeaseId: input.environmentLeaseId,
|
|
normalizedSessionId: input.normalizedSessionId,
|
|
turnId: input.turnId,
|
|
itemId: input.itemId,
|
|
runnerVersion: RUNNER_VERSION,
|
|
runnerDigest,
|
|
});
|
|
|
|
prepared.queueCommand("run.prepare", buildNativeRunnerPreparePayload({
|
|
cwd: input.cwd,
|
|
model: input.model,
|
|
resumeProviderSessionId: input.resumeProviderSessionId,
|
|
completionContract: input.completionContract,
|
|
semanticTools: prepared.semanticTools,
|
|
...(input.providerLaunch ? { providerLaunch: input.providerLaunch } : {}),
|
|
}), `prepare_${input.runId}`);
|
|
prepared.queueCommand("session.open", {}, `open_${input.runId}`);
|
|
const recoveryGoalRequestId = input.resumeSessionGoalHeartbeat
|
|
? `recovery_${input.runId}`
|
|
: null;
|
|
if (goalControl) {
|
|
const commandKey = createHash("sha256").update(goalControl.requestId).digest("hex").slice(0, 20);
|
|
nativeGoalCommands(goalControl).forEach((command, index) => {
|
|
prepared.queueCommand(command.type, command.payload, `goal_${commandKey}_${index + 1}`);
|
|
});
|
|
await input.db.update(agentSessionGoalActions).set({
|
|
status: "delivering",
|
|
updatedAt: new Date(),
|
|
}).where(and(
|
|
eq(agentSessionGoalActions.sessionId, goalControl.sessionId),
|
|
eq(agentSessionGoalActions.requestId, goalControl.requestId),
|
|
));
|
|
} else if (recoveryGoalRequestId) {
|
|
prepared.queueCommand(
|
|
"session.goal.set",
|
|
{ requestId: recoveryGoalRequestId, status: "active" },
|
|
`goal_${recoveryGoalRequestId}`,
|
|
);
|
|
} else {
|
|
prepared.queueCommand("turn.start", { text: input.prompt }, `turn_${input.runId}`);
|
|
}
|
|
|
|
const child = spawn(binary, buildNativeRunnerArguments({
|
|
connectUrl: prepared.connectUrl,
|
|
stateDirectory: runnerStateDirectory,
|
|
runnerInstanceId: input.runnerInstanceId,
|
|
environmentLeaseId: input.environmentLeaseId,
|
|
runId: input.runId,
|
|
normalizedSessionId: input.normalizedSessionId,
|
|
turnId: input.turnId,
|
|
itemId: input.itemId,
|
|
runnerDigest,
|
|
maxRuntimeMs: input.timeoutMs,
|
|
}), {
|
|
cwd: input.cwd,
|
|
detached: process.platform !== "win32",
|
|
env: {
|
|
...process.env,
|
|
...input.environment,
|
|
PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: prepared.bootstrapTicket,
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const exit = waitForExit(child);
|
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
void input.onLog("stdout", chunk.toString("utf8"));
|
|
});
|
|
child.stderr?.on("data", (chunk: Buffer) => {
|
|
void input.onLog("stderr", chunk.toString("utf8"));
|
|
});
|
|
|
|
try {
|
|
if (!child.pid) throw new Error("paperclip_runner_process_not_started");
|
|
await input.onSpawn({
|
|
pid: child.pid,
|
|
processGroupId: process.platform === "win32" ? null : child.pid,
|
|
startedAt: new Date().toISOString(),
|
|
});
|
|
if (goalControl) {
|
|
const commandKey = createHash("sha256").update(goalControl.requestId).digest("hex").slice(0, 20);
|
|
const commands = nativeGoalCommands(goalControl);
|
|
for (let index = 0; index < commands.length; index += 1) {
|
|
await prepared.waitForCommand(`goal_${commandKey}_${index + 1}`, Math.min(input.timeoutMs, 30_000));
|
|
}
|
|
await prepared.waitForGoalEvent(goalControl.requestId, Math.min(input.timeoutMs, 30_000));
|
|
} else if (recoveryGoalRequestId) {
|
|
await prepared.waitForCommand(`goal_${recoveryGoalRequestId}`, Math.min(input.timeoutMs, 30_000));
|
|
await prepared.waitForGoalEvent(recoveryGoalRequestId, Math.min(input.timeoutMs, 30_000));
|
|
}
|
|
const shouldWaitForGoalCompletion = goalControl && (
|
|
goalControl.workingNow ||
|
|
["create", "replace", "resume"].includes(goalControl.action) ||
|
|
(goalControl.action === "edit" && goalControl.currentStatus === "active")
|
|
);
|
|
const completion = goalControl && !shouldWaitForGoalCompletion
|
|
? Promise.resolve(null)
|
|
: prepared.waitForTerminal(input.timeoutMs);
|
|
const completed = await Promise.race([
|
|
completion,
|
|
exit.then(async ({ code, signal }) => {
|
|
const recovered = goalControl && !shouldWaitForGoalCompletion
|
|
? null
|
|
: await prepared.waitForTerminal(2_000).catch(() => undefined);
|
|
if (recovered) return recovered;
|
|
if (recovered === null && goalControl && !shouldWaitForGoalCompletion) return null;
|
|
throw new Error(
|
|
`paperclip_runner_process_exited: code=${code ?? "null"} signal=${signal ?? "null"}`,
|
|
);
|
|
}),
|
|
]);
|
|
prepared.queueCommand("session.close", {}, `close_${input.runId}`);
|
|
prepared.queueCommand("runner.shutdown", {}, `shutdown_${input.runId}`);
|
|
await stopChild(child, exit, true);
|
|
|
|
const succeeded = completed === null || completed.terminal.runTerminalState === "succeeded";
|
|
if (goalControl && completed === null) {
|
|
await input.db.update(agentSessionGoalActions).set({
|
|
status: "completed",
|
|
completedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}).where(and(
|
|
eq(agentSessionGoalActions.sessionId, goalControl.sessionId),
|
|
eq(agentSessionGoalActions.requestId, goalControl.requestId),
|
|
));
|
|
}
|
|
return {
|
|
exitCode: succeeded ? 0 : 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
...(succeeded ? {} : {
|
|
errorCode: "paperclip_runner_provider_failed",
|
|
errorMessage: completed?.result.summary,
|
|
}),
|
|
provider: "codex",
|
|
model: input.model,
|
|
sessionParams: {
|
|
sessionId: completed?.providerSessionId ?? input.normalizedSessionId,
|
|
},
|
|
sessionDisplayId: completed?.providerSessionId ?? input.normalizedSessionId,
|
|
resultJson: {
|
|
nativeRunner: {
|
|
...(completed ? { result: completed.result, terminal: completed.terminal } : {}),
|
|
...(goalControl ? {
|
|
goalControl: {
|
|
requestId: goalControl.requestId,
|
|
action: goalControl.action,
|
|
status: "completed",
|
|
},
|
|
} : {}),
|
|
...(recoveryGoalRequestId ? {
|
|
goalHeartbeat: {
|
|
requestId: recoveryGoalRequestId,
|
|
status: "settled",
|
|
},
|
|
} : {}),
|
|
},
|
|
},
|
|
summary: completed?.result.summary ?? "Agent session goal control applied.",
|
|
};
|
|
} catch (error) {
|
|
if (goalControl) {
|
|
await failRunnerGoalAction(
|
|
input.db,
|
|
{
|
|
companyId: input.companyId,
|
|
issueId: input.issueId,
|
|
agentId: input.agentId,
|
|
adapterType: "paperclip_runner",
|
|
},
|
|
goalControl.requestId,
|
|
error instanceof Error ? error.message : "native_goal_control_failed",
|
|
).catch(() => undefined);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
await stopChild(child, exit).catch(() => undefined);
|
|
await prepared.release();
|
|
}
|
|
}
|