mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip manages agents and the work they perform. > - Product E2E tests exercise real agents, tools, setup and cleanup. > - Repeated runs need retained costs and traces, including failed attempts. > - A passing retry must not hide earlier spending or feedback. > - Recurring work also needs admission limits, private reports and failure detection. > - This change supplies those controls while keeping paid activation behind existing gates. ## Linked Issues or Issue Description Refs #16996 and #17158. This is the consolidated landing of the reviewed eval operations stack: #17019, #17024, #17025, #17027, #17030, #17033, #17035, #17038, #17043, #17044, #17048, #17050, #17051, #17092, #17096, #17100, #17107, #17109, #17112, #17115, #17117, #17121, #17123, #17127, #17130, #17133, #17134, #17137, #17140, #17143, #17154. The repository permits squash merges only. A local rehearsal showed that squashing each slice into master causes repeated conflicts in dependent slices. This PR preserves their commits and lands their combined implementation in one reviewed integration. Superseded slices will close after this merge. ## What Changed - Retain every attempt's cost receipt, including setup, failures, interruptions and retries. Separate reported amounts, estimates, missing costs and reserved capacity in reports and dashboards. - Add bounded request admission, exact model pricecards, gateway error accounting, cache policy and reconciliation evidence. Refuse unsupported routes and requests that cannot fit the allowance. - Integrate Braintrust gateway traces and OTLP correlation, with secret filtering and experiment imports. Record phase timing, including setup, feedback capture and cleanup. - Collect complaint and suggestion evidence after each attempt, including recovery after failures. Retain explicit review dispositions independently from the product grade. - Add gated daily and weekly selections, measured forecasts, setup/readiness checks, wall-time limits and retained monitor notifications. Include declared non-model overhead in admission while reporting the existing subscription separately. - Keep recurring history private, bound uploads and retention, and use isolated archive environments. Keep public publication an explicit operation. Report and publication jobs install only the committed lockfile, with scripts disabled and isolated stores; stale pins fail closed. - Integrate current master, preserve its Product E2E journey billing and privacy checks, and test that shared setup receipts do not double-count estimated agent usage. Preserve upstream fixture repairs. ## Verification - Complete integration baseline `0649a5ce7544ab20825c2183d2f421157db49f24` includes master `a3e1d915a7110a4e115b46396a3f79e6dfb4d05e` and every original stack head as ancestors. - On that integration baseline, `pnpm test:e2e:runner:unit`: 4,012 Vitest tests passed, two skipped, across 193 files; all 137 Node source-contract tests passed. - Focused billing and Product E2E journey contract checks: 24 tests passed. They cover estimates, unknown receipts, single-owner setup costs, browser-only execution and private publication guards. - Monitor and workflow wiring: all 96 Node tests passed. `node scripts/check-token-gates.mjs`: all gates clean. - `pnpm test:e2e:runner:typecheck` passes after the standard Runner TypeScript build refreshed local declarations. The first attempt rejected the new master `runner_prepared_launch` union member because this checkout retained an older generated declaration; the source union already contained that member. - Current head `492b0708a1c531a023d9ac10f0f3dc6383cc1754` removes dependency re-resolution from archive-authorized reporting jobs after the security scan flagged it. All 15 workflow-security tests pass after updating the old two-install assertion. - Hosted [CI run 39965460180](https://github.com/paperclipai/paperclip/actions/runs/39965460180) passes on the current repaired head, including typecheck, build, general tests, Runner checks, browser shards and canary dry run. Greptile is 5/5 on this exact head with no unresolved threads. The security scan passes after the fixed lockfile-resolution thread was resolved. - Paperclip Review returned a terminal neutral check but an incomplete 0-file assessment on the repaired head: its platform blocked both diff responses as prompt-injection content. This is not a successful code review. No bypass or fabricated pass was attempted; current-head Greptile, security and hosted CI are independent gates. - No paid test, reservation release or recurring-run activation accompanies this landing. Live route qualification and invoice reconciliation remain separate evidence. <details> <summary>Retained earlier validation failures</summary> Earlier broad local runs had two 15-second test timeouts (CLI poll cadence and Retry validation), one embedded-database setup timeout, and later PostgreSQL semaphore exhaustion. The exact timed-out tests passed separately without larger timeouts; the broad-run causes were not established. Those local failures remain distinct from subsequent hosted CI results. The independent #17158 fix records its Muse fixture collision, its UUID correction and hosted verification. Local Muse execution was also blocked by macOS embedded-PostgreSQL startup. No historical failure is treated as a successful paid qualification. </details> ## Risks - Admission is a local control, not a provider invoice cap. Missing provider receipts or resource charges retain unknown amounts and holds; estimates do not become billed dollars. - The bounded path admits only documented local suites and provider routes. Ordinary legacy or separate Runner protocol campaigns have different controls. Merging this code does not qualify additional routes. - Recurrence still needs the existing enable gates and current qualification/allowance evidence. Archive permissions and resources must match the documented private-environment contract. - Legacy reports may lack complete attempt or phase evidence. Reports preserve that limitation and do not infer zero usage from missing data. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools and code execution. The exact deployment variant and context-window size are not exposed by this session. ## 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 (targeted checks; full-suite host limitation disclosed above) - [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>
1226 lines
49 KiB
TypeScript
1226 lines
49 KiB
TypeScript
import { recoverWorkerTiming } from "./worker-timing.js";
|
|
import { openEvalOperations, type EvalOperations } from "./operations.js";
|
|
import { readRecoveredFeedback } from "./feedback-recovery.js";
|
|
import { readInterruptedAttemptTrace } from "./trace-recovery.js";
|
|
import { EVAL_OBSERVABILITY_SECRET_NAMES, evalBraintrustConfig, finalizeEvalAttemptTrace } from "./braintrust.js";
|
|
import { finalizeEvalAttemptThroughRelay } from "./eval-finalization-relay.js";
|
|
import { runnerE2EPlaywrightInvocation, runnerE2EPlaywrightShutdownSignal } from "./web-server-command.js";
|
|
import { verifyInstalledDaytonaPlugin } from "./installed-daytona-plugin.js";
|
|
import { assertInstalledCliSelection, assertInstalledStartupOnly, verifyInstalledCli } from "./installed-cli.js";
|
|
import { createProcessTreeOwner, stopOwnedProcessTree } from "./process-tree-owner.js";
|
|
import { createRunnerE2ETemporaryRoot } from "./server-config.js";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { prepareCodexCiSandbox, requiresCodexCiSandbox } from "./codex-ci-sandbox.js";
|
|
import { spawn } from "node:child_process";
|
|
import { createWriteStream } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
access,
|
|
chmod,
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
readFile,
|
|
realpath,
|
|
readdir,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { isImmutableDaytonaImage, runnerMatrix } from "./catalog.js";
|
|
import { renderRunnerE2EDashboard } from "./dashboard.js";
|
|
import { packageEvidence } from "./evidence.js";
|
|
import { classifyFailure } from "./failure-classifier.js";
|
|
import { mustPreserveRecoveryState, shouldKeepFailedDiagnostics } from "./cleanup-verification.js";
|
|
import { effectiveAutomaticRetryLimit, executeWithAutomaticRetry } from "./automatic-retry.js";
|
|
import { buildRunnerCampaign } from "./history.js";
|
|
import { readEvalOperationsReport } from "./operations-report.js";
|
|
import {
|
|
applyEvalGatewayEnvironment,
|
|
buildRunnerE2EProcessEnvironment,
|
|
resolvePaperclipRemoteRunnerBinaryForHarness,
|
|
resolvePaperclipRunnerBinaryForHarness,
|
|
} from "./harness-env.js";
|
|
import { assertEmbeddedDatabaseIsolation } from "./instance-isolation.js";
|
|
import {
|
|
assertSecretFree,
|
|
findSecretLeakInDirectory,
|
|
isEphemeralCodexRuntimeAuthFile,
|
|
isEphemeralPostgresScanFile,
|
|
normalizedSecrets,
|
|
secretScanFailureEvidence,
|
|
sanitizeJson,
|
|
} from "./redaction.js";
|
|
import {
|
|
buildMatrixJobs,
|
|
parseRunnerSelectors,
|
|
RunnerSelectorError,
|
|
selectRunnerExecutions,
|
|
} from "./selectors.js";
|
|
import { resolveRunnerE2ESource } from "./source.js";
|
|
import {
|
|
CREDENTIAL_NAMES,
|
|
type MatrixExecution,
|
|
type RunnerE2EResult,
|
|
} from "./types.js";
|
|
import { assertRemoteNativeEvidencePrerequisites, assertRunnerE2EPrerequisites } from "./prerequisites.js";
|
|
import { assertNativeCompletionSelection, prepareNativeCompletionPreflight, NATIVE_COMPLETION_PREFLIGHT_ENV } from "./native-completion-admission.js";
|
|
import { assertNativeInstructionSelection, prepareNativeInstructionPreflight, NATIVE_INSTRUCTION_PREFLIGHT_ENV, NATIVE_INSTRUCTION_SUITE } from "./native-instruction-consolidation.js";
|
|
import { prepareStockHarnessPreflight, STOCK_PREFLIGHT_ENV } from "./stock-harness-admission.js";
|
|
|
|
import {
|
|
reapNewDetachedDarwinSharedMemory,
|
|
snapshotDarwinSharedMemory,
|
|
} from "./shared-memory.js";
|
|
import { reserveRunnerE2EServerPort } from "./ports.js";
|
|
import {
|
|
createResultExitGuard,
|
|
enforceResultProcessIntegrity,
|
|
} from "./result-exit-guard.js";
|
|
import {
|
|
observeDescendantProcessTree,
|
|
type ObservedProcessGroup,
|
|
readProcessTable,
|
|
} from "./process-tree.js";
|
|
|
|
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
|
|
const localEnvPath = path.join(repositoryRoot, ".env.runner-e2e.local");
|
|
const resultsRoot = path.join(repositoryRoot, "tests/runner-e2e/results");
|
|
const activeProcessGroups = new Set<number>();
|
|
const activeProcessCleanup = new Map<number, Promise<string | null>>();
|
|
const activeProcessTerminators = new Map<number, () => void>();
|
|
const completedResultExitGraceMs = 120_000;
|
|
let cancelled = false;
|
|
|
|
function cleanId(value: string) {
|
|
const result = value
|
|
.replace(/[^A-Za-z0-9_.-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
if (!result)
|
|
throw new Error(
|
|
`Invalid empty identifier derived from ${JSON.stringify(value)}`,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
function secret(bytes = 32) {
|
|
return randomBytes(bytes).toString("base64url");
|
|
}
|
|
|
|
async function makeDirectoryTreeRemovable(directory: string): Promise<void> {
|
|
await chmod(directory, 0o700);
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
await Promise.all(
|
|
entries
|
|
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
|
.map((entry) =>
|
|
makeDirectoryTreeRemovable(path.join(directory, entry.name)),
|
|
),
|
|
);
|
|
}
|
|
|
|
function stopProcessGroup(pid: number, signal: NodeJS.Signals = "SIGTERM") {
|
|
try {
|
|
if (process.platform === "win32") process.kill(pid, signal);
|
|
else process.kill(-pid, signal);
|
|
} catch {
|
|
// The process tree already exited.
|
|
}
|
|
}
|
|
|
|
function processGroupIsAlive(pid: number) {
|
|
try {
|
|
process.kill(process.platform === "win32" ? pid : -pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function terminateProcessGroup(pid: number) {
|
|
stopProcessGroup(pid, "SIGTERM");
|
|
const gracefulDeadline = Date.now() + 5_000;
|
|
while (processGroupIsAlive(pid) && Date.now() < gracefulDeadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
if (!processGroupIsAlive(pid)) return null;
|
|
stopProcessGroup(pid, "SIGKILL");
|
|
const forcedDeadline = Date.now() + 5_000;
|
|
while (processGroupIsAlive(pid) && Date.now() < forcedDeadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
return processGroupIsAlive(pid)
|
|
? `Paperclip/Playwright process group ${pid} survived SIGKILL`
|
|
: null;
|
|
}
|
|
|
|
function wait(milliseconds: number) {
|
|
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
interface ProcessTreeDiagnostic {
|
|
summary: string;
|
|
groups: ObservedProcessGroup[];
|
|
}
|
|
|
|
async function processTreeDiagnostic(
|
|
rootPid: number,
|
|
): Promise<ProcessTreeDiagnostic> {
|
|
const table = await readProcessTable();
|
|
if (!table) {
|
|
return {
|
|
summary: `process tree ${rootPid} (member inspection unavailable)`,
|
|
groups: [],
|
|
};
|
|
}
|
|
const observed = observeDescendantProcessTree(table, rootPid);
|
|
const members = observed.members
|
|
.slice(0, 64)
|
|
.map(
|
|
({ process: candidate, depth }) =>
|
|
`pid=${candidate.pid} ppid=${candidate.parentPid} pgid=${candidate.processGroupId} depth=${depth} kind=${candidate.kind}`,
|
|
);
|
|
const descendantGroupIds = observed.groups
|
|
.filter((group) => group.processGroupId !== rootPid)
|
|
.map((group) => group.processGroupId);
|
|
return {
|
|
summary:
|
|
members.length > 0
|
|
? `process tree ${rootPid}: ${members.join("; ")}; descendant pgids=${descendantGroupIds.join(",") || "none"}`
|
|
: `process tree ${rootPid}: no members reported`,
|
|
groups: observed.groups,
|
|
};
|
|
}
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
process.on(signal, () => {
|
|
cancelled = true;
|
|
for (const pid of activeProcessGroups) {
|
|
const terminate = activeProcessTerminators.get(pid);
|
|
if (terminate) {
|
|
terminate();
|
|
continue;
|
|
}
|
|
activeProcessCleanup.set(pid, terminateProcessGroup(pid));
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadLocalEnvironment(target: NodeJS.ProcessEnv) {
|
|
const contents = await readFile(localEnvPath, "utf8").catch(
|
|
(error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
},
|
|
);
|
|
if (contents === null) return;
|
|
for (const [index, rawLine] of contents.split(/\r?\n/).entries()) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith("#")) continue;
|
|
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
|
|
if (!match)
|
|
throw new Error(
|
|
`Invalid ${path.basename(localEnvPath)} line ${index + 1}`,
|
|
);
|
|
if (target[match[1]] !== undefined) continue;
|
|
let value = match[2].trim();
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
target[match[1]] = value;
|
|
}
|
|
}
|
|
|
|
async function prepareProviderPath(
|
|
temporaryRoot: string,
|
|
inheritedPath: string | undefined,
|
|
) {
|
|
const toolBin = path.join(temporaryRoot, "provider-bin");
|
|
await mkdir(toolBin, { recursive: true });
|
|
const runnerRequire = createRequire(
|
|
path.join(repositoryRoot, "packages/paperclip-runner/package.json"),
|
|
);
|
|
const codexAcpPackage = runnerRequire.resolve(
|
|
"@agentclientprotocol/codex-acp/package.json",
|
|
);
|
|
const codexRequire = createRequire(codexAcpPackage);
|
|
const codexPackage = codexRequire.resolve("@openai/codex/package.json");
|
|
const codexManifest = JSON.parse(await readFile(codexPackage, "utf8")) as {
|
|
bin?: string | Record<string, string>;
|
|
};
|
|
const codexBin =
|
|
typeof codexManifest.bin === "string"
|
|
? codexManifest.bin
|
|
: codexManifest.bin?.codex;
|
|
if (!codexBin)
|
|
throw new Error(
|
|
"Production Codex ACP dependency does not expose its pinned Codex executable",
|
|
);
|
|
await symlink(
|
|
path.resolve(path.dirname(codexPackage), codexBin),
|
|
path.join(toolBin, "codex"),
|
|
);
|
|
const packageBin = path.join(
|
|
repositoryRoot,
|
|
"packages/paperclip-runner/node_modules/.bin",
|
|
);
|
|
return [toolBin, packageBin, inheritedPath]
|
|
.filter(Boolean)
|
|
.join(path.delimiter);
|
|
}
|
|
|
|
async function runProcess(
|
|
invocation: { command: string; args: string[] },
|
|
env: NodeJS.ProcessEnv,
|
|
timeoutMs: number | null,
|
|
logPath: string,
|
|
completionPaths: readonly string[],
|
|
interactive: boolean,
|
|
) {
|
|
const log = createWriteStream(logPath, { flags: "a", mode: 0o600 });
|
|
const child = spawn(invocation.command, invocation.args, {
|
|
cwd: repositoryRoot,
|
|
env,
|
|
stdio: ["inherit", "pipe", "pipe"],
|
|
detached: process.platform !== "win32",
|
|
});
|
|
if (!child.pid) throw new Error("Failed to start Playwright");
|
|
activeProcessGroups.add(child.pid);
|
|
const processOwner = createProcessTreeOwner(child);
|
|
let outputTail = "";
|
|
const recordOutput = (chunk: Buffer, destination: NodeJS.WriteStream) => {
|
|
destination.write(chunk);
|
|
log.write(chunk);
|
|
outputTail += chunk.toString("utf8");
|
|
if (outputTail.length > 1024 * 1024)
|
|
outputTail = outputTail.slice(-1024 * 1024);
|
|
};
|
|
child.stdout?.on("data", (chunk: Buffer) =>
|
|
recordOutput(chunk, process.stdout),
|
|
);
|
|
child.stderr?.on("data", (chunk: Buffer) =>
|
|
recordOutput(chunk, process.stderr),
|
|
);
|
|
let childSettled = false;
|
|
let postResultStallError: string | null = null;
|
|
let boundedCleanup: Promise<string | null> | undefined;
|
|
let cleanupSettled!: () => void;
|
|
const cleanupFinished = new Promise<number>(resolve => { cleanupSettled = () => resolve(1); });
|
|
const stopChildTree = (_diagnostic?: ProcessTreeDiagnostic) => {
|
|
if (boundedCleanup) return;
|
|
boundedCleanup = stopOwnedProcessTree(child, processOwner, 45_000, 5_000, runnerE2EPlaywrightShutdownSignal)
|
|
.then(() => null, error => error instanceof Error ? error.message : String(error))
|
|
.finally(cleanupSettled);
|
|
activeProcessCleanup.set(child.pid!, boundedCleanup);
|
|
};
|
|
activeProcessTerminators.set(child.pid, stopChildTree);
|
|
const resultExitGuard = createResultExitGuard({
|
|
resultPaths: completionPaths,
|
|
interactive,
|
|
graceMs: completedResultExitGraceMs,
|
|
now: Date.now,
|
|
pathExists: (candidate) =>
|
|
access(candidate).then(
|
|
() => true,
|
|
() => false,
|
|
),
|
|
onExpired: async () => {
|
|
const diagnostic = await processTreeDiagnostic(child.pid!);
|
|
if (childSettled) return;
|
|
postResultStallError = `Playwright remained alive for ${completedResultExitGraceMs}ms after every result was written`;
|
|
recordOutput(
|
|
Buffer.from(
|
|
`\n${postResultStallError}; forcing bounded cleanup. ${diagnostic.summary}\n`,
|
|
),
|
|
process.stderr,
|
|
);
|
|
stopChildTree(diagnostic);
|
|
},
|
|
});
|
|
const completionPoll = resultExitGuard.enabled
|
|
? setInterval(() => {
|
|
void resultExitGuard.poll().catch(() => {
|
|
if (childSettled || postResultStallError) return;
|
|
postResultStallError =
|
|
"Completed-result exit guard failed during bounded inspection";
|
|
recordOutput(
|
|
Buffer.from(`\n${postResultStallError}; forcing cleanup.\n`),
|
|
process.stderr,
|
|
);
|
|
stopChildTree();
|
|
});
|
|
}, 500)
|
|
: undefined;
|
|
completionPoll?.unref();
|
|
let timedOut = false;
|
|
const timer =
|
|
timeoutMs === null
|
|
? undefined
|
|
: setTimeout(() => {
|
|
timedOut = true;
|
|
stopChildTree();
|
|
}, timeoutMs);
|
|
timer?.unref();
|
|
let spawnError: string | null = null;
|
|
const childExit = new Promise<number>((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("exit", (code) => {
|
|
childSettled = true;
|
|
resolve(code ?? 1);
|
|
});
|
|
}).catch((error) => {
|
|
childSettled = true;
|
|
spawnError = error instanceof Error ? error.message : String(error);
|
|
return 1;
|
|
});
|
|
const exitCode = await Promise.race([childExit, cleanupFinished]);
|
|
if (timer) clearTimeout(timer);
|
|
if (completionPoll) clearInterval(completionPoll);
|
|
// Even a successful launcher exit can leave an already-observed detached
|
|
// server behind. Retire that retained tree, never only the root group.
|
|
stopChildTree();
|
|
const processCleanupError = await boundedCleanup!;
|
|
processOwner.stopObserving();
|
|
// Even a failed direct-child kill must not hold cancellation forever or let
|
|
// inherited pipes write into an ended log. The cleanup error remains fatal.
|
|
if (processCleanupError) {
|
|
child.stdout?.destroy();
|
|
child.stderr?.destroy();
|
|
if (child.exitCode === null && child.signalCode === null) child.unref();
|
|
}
|
|
if (child.pid) {
|
|
activeProcessGroups.delete(child.pid);
|
|
activeProcessCleanup.delete(child.pid);
|
|
activeProcessTerminators.delete(child.pid);
|
|
}
|
|
await new Promise<void>((resolve) => log.end(resolve));
|
|
return {
|
|
exitCode,
|
|
timedOut,
|
|
postResultStallError,
|
|
processCleanupError,
|
|
spawnError,
|
|
outputTail,
|
|
};
|
|
}
|
|
|
|
function syntheticResult(
|
|
execution: MatrixExecution,
|
|
attempt: number,
|
|
startedAtMs: number,
|
|
error: string,
|
|
failureClass: RunnerE2EResult["failureClass"] = "transient_infrastructure",
|
|
): RunnerE2EResult {
|
|
const finishedAtMs = Date.now();
|
|
return {
|
|
schema: "paperclip.runner-e2e.result/v2",
|
|
executionId: execution.id,
|
|
suiteId: execution.suite.id,
|
|
suiteDefinitionHash: execution.suiteDefinitionHash,
|
|
source: resolveRunnerE2ESource(),
|
|
...(execution.profile.ranking
|
|
? { rankingSnapshot: execution.profile.ranking }
|
|
: {}),
|
|
attempt,
|
|
status: "failed",
|
|
failureClass,
|
|
error,
|
|
profileId: execution.profile.id,
|
|
environmentId: execution.environment.id,
|
|
caseId: execution.task.id,
|
|
provider: execution.profile.provider,
|
|
model: execution.profile.model,
|
|
runtimeMode: execution.profile.expectedRuntimeMode,
|
|
startedAt: new Date(startedAtMs).toISOString(),
|
|
finishedAt: new Date(finishedAtMs).toISOString(),
|
|
durationMs: finishedAtMs - startedAtMs,
|
|
cleanup: "not_started",
|
|
};
|
|
}
|
|
|
|
async function readResult(resultPath: string, fallback: RunnerE2EResult) {
|
|
try {
|
|
return JSON.parse(await readFile(resultPath, "utf8")) as RunnerE2EResult;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
async function copySharedEvidence(privateRoot: string, casePrivateDir: string) {
|
|
for (const relative of [
|
|
"server.log",
|
|
"playwright.log",
|
|
"junit.xml",
|
|
"html-report",
|
|
"blob-report",
|
|
"playwright-output",
|
|
]) {
|
|
await cp(
|
|
path.join(privateRoot, relative),
|
|
path.join(casePrivateDir, relative),
|
|
{ recursive: true, force: true },
|
|
).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code !== "ENOENT") throw error;
|
|
});
|
|
}
|
|
}
|
|
|
|
async function runAttempt(input: {
|
|
executions: readonly MatrixExecution[];
|
|
attempt: number;
|
|
campaignId: string;
|
|
options: ReturnType<typeof parseRunnerSelectors>;
|
|
operations?: EvalOperations;
|
|
}): Promise<RunnerE2EResult[]> {
|
|
const { executions, attempt, campaignId, options } = input;
|
|
const accountingCampaignId = input.operations?.manifest.limits.campaignId ?? process.env.PAPERCLIP_E2E_ACCOUNTING_CAMPAIGN_ID ?? campaignId;
|
|
const accountingAttemptId = randomBytes(16).toString("hex");
|
|
const traceConfig = evalBraintrustConfig({ ...process.env, PAPERCLIP_E2E_OTLP_RELAY_URL: undefined });
|
|
const execution = executions[0];
|
|
if (!execution) throw new Error("A runner E2E cell must contain a task case");
|
|
if (
|
|
executions.some(
|
|
(candidate) =>
|
|
candidate.profile.id !== execution.profile.id ||
|
|
candidate.environment.id !== execution.environment.id,
|
|
)
|
|
) {
|
|
throw new Error(
|
|
"One isolated runner E2E harness cannot mix profiles or environments",
|
|
);
|
|
}
|
|
assertInstalledCliSelection(process.env, executions);
|
|
const installedCli = await verifyInstalledCli(process.env, executions);
|
|
const installedPlugin = await verifyInstalledDaytonaPlugin(process.env, executions);
|
|
const startedAtMs = Date.now();
|
|
const sharedMemoryBaseline = snapshotDarwinSharedMemory();
|
|
const temporaryParent = await realpath(os.tmpdir());
|
|
const temporaryRoot = await createRunnerE2ETemporaryRoot(temporaryParent);
|
|
const publishedResults: RunnerE2EResult[] = [];
|
|
const publishedResultPaths = new Map<string, string>();
|
|
let attemptSecrets: string[] = [];
|
|
let processCleanupFailed = false;
|
|
let startupReceiptPath: string | undefined;
|
|
let gateway: Awaited<ReturnType<EvalOperations["start"]>> | undefined;
|
|
const rawCleanupResults: Array<{ cleanup: string; synthetic: boolean; status: string }> = [];
|
|
try {
|
|
const paperclipHome = path.join(temporaryRoot, "paperclip-home");
|
|
const workspace = path.join(temporaryRoot, "workspace");
|
|
const privateDir = path.join(temporaryRoot, "artifacts-private");
|
|
const instanceId = `runner-e2e-${randomBytes(8).toString("hex")}`;
|
|
const configPath = path.join(
|
|
paperclipHome,
|
|
"instances",
|
|
instanceId,
|
|
"config.json",
|
|
);
|
|
const port = await reserveRunnerE2EServerPort();
|
|
await Promise.all([
|
|
mkdir(paperclipHome, { recursive: true }),
|
|
mkdir(workspace, { recursive: true }),
|
|
mkdir(privateDir, { recursive: true }),
|
|
]);
|
|
const providerPath = await prepareProviderPath(
|
|
temporaryRoot,
|
|
process.env.PATH,
|
|
);
|
|
if (requiresCodexCiSandbox(execution)) {
|
|
await prepareCodexCiSandbox(repositoryRoot, temporaryRoot);
|
|
}
|
|
const agentJwtSecret = secret(48);
|
|
const decisionSigningSecret = secret(48);
|
|
const toolActionSigningSecret = secret(48);
|
|
const betterAuthSecret = secret(48);
|
|
gateway = await input.operations?.start(execution, accountingAttemptId, attempt);
|
|
const credentialSources = {
|
|
PAPERCLIP_E2E_GATEWAY_TOKEN: gateway?.token,
|
|
PAPERCLIP_E2E_OTLP_RELAY_TOKEN: gateway?.observability?.token,
|
|
...Object.fromEntries([...CREDENTIAL_NAMES, ...EVAL_OBSERVABILITY_SECRET_NAMES].map((name) => [name, process.env[name]])),
|
|
PAPERCLIP_AGENT_JWT_SECRET: agentJwtSecret,
|
|
PAPERCLIP_DECISION_SIGNING_SECRET: decisionSigningSecret,
|
|
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: toolActionSigningSecret,
|
|
BETTER_AUTH_SECRET: betterAuthSecret,
|
|
};
|
|
const credentials = normalizedSecrets(Object.values(credentialSources));
|
|
attemptSecrets = credentials;
|
|
if (installedCli) await writeFile(path.join(privateDir, "installed-cli-admission.json"), `${JSON.stringify(installedCli, null, 2)}\n`, { mode: 0o600 });
|
|
if (installedPlugin) await writeFile(path.join(privateDir, "installed-daytona-plugin-admission.json"), `${JSON.stringify(installedPlugin, null, 2)}\n`, { mode: 0o600 });
|
|
const runnerBinary = installedCli ? undefined : resolvePaperclipRunnerBinaryForHarness(
|
|
executions,
|
|
repositoryRoot,
|
|
);
|
|
const childEnv: NodeJS.ProcessEnv = {
|
|
...buildRunnerE2EProcessEnvironment(process.env, executions),
|
|
PATH: providerPath,
|
|
PAPERCLIP_RUNNER_E2E_EXECUTION_IDS: JSON.stringify(
|
|
executions.map((candidate) => candidate.id),
|
|
),
|
|
PAPERCLIP_RUNNER_E2E_ATTEMPT: String(attempt),
|
|
PAPERCLIP_E2E_ACCOUNTING_CAMPAIGN_ID: accountingCampaignId,
|
|
PAPERCLIP_E2E_ACCOUNTING_ATTEMPT_ID: accountingAttemptId,
|
|
PAPERCLIP_RUNNER_E2E_INSTALLED_STARTUP_ONLY: options.installedStartupOnly ? "1" : undefined,
|
|
PAPERCLIP_RUNNER_E2E_PUBLIC_MCP: executions.some(candidate => candidate.task.flow === "public_mcp") ? "1" : "0",
|
|
PAPERCLIP_RUNNER_E2E_PORT: String(port),
|
|
PAPERCLIP_RUNNER_E2E_TEMP_ROOT: temporaryRoot,
|
|
PAPERCLIP_RUNNER_E2E_PRIVATE_DIR: privateDir,
|
|
PAPERCLIP_RUNNER_E2E_WORKSPACE: workspace,
|
|
PAPERCLIP_RUNNER_E2E_SERVER_LOG: path.join(privateDir, "server.log"),
|
|
PAPERCLIP_RUNNER_BINARY: runnerBinary,
|
|
PAPERCLIP_RUNNER_REMOTE_BINARY_PATH:
|
|
installedCli ? undefined : resolvePaperclipRemoteRunnerBinaryForHarness(executions, runnerBinary),
|
|
// Vite's optimized dependency cache embeds revision query strings. A
|
|
// private per-attempt cache prevents an earlier cell or local rebuild
|
|
// from producing `504 Outdated Optimize Dep` during browser bootstrap.
|
|
PAPERCLIP_VITE_CACHE_DIR: path.join(temporaryRoot, "vite-cache"),
|
|
PAPERCLIP_RUNNER_E2E_TEST_TIMEOUT_MS: String(
|
|
Math.max(
|
|
...executions.map(
|
|
(candidate) =>
|
|
candidate.task.attemptTimeoutMs[candidate.environment.id],
|
|
),
|
|
) + 90_000,
|
|
),
|
|
PAPERCLIP_HOME: paperclipHome,
|
|
PAPERCLIP_INSTANCE_ID: instanceId,
|
|
PAPERCLIP_CONFIG: configPath,
|
|
PAPERCLIP_AGENT_JWT_SECRET: agentJwtSecret,
|
|
PAPERCLIP_DECISION_SIGNING_SECRET: decisionSigningSecret,
|
|
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: toolActionSigningSecret,
|
|
BETTER_AUTH_SECRET: betterAuthSecret,
|
|
};
|
|
if (gateway) {
|
|
applyEvalGatewayEnvironment(childEnv, execution.profile.credential, gateway);
|
|
}
|
|
// The database URLs are stripped here and again at the Playwright web-server
|
|
// boundary so a developer's shell can never redirect this paid test.
|
|
delete childEnv.DATABASE_URL;
|
|
delete childEnv.DATABASE_MIGRATION_URL;
|
|
|
|
const playwrightArgs = [
|
|
"test",
|
|
"--config",
|
|
"tests/runner-e2e/playwright.config.ts",
|
|
...(options.headed ? ["--headed"] : []),
|
|
...(options.ui ? ["--ui"] : []),
|
|
...(options.debug ? ["--debug"] : []),
|
|
];
|
|
const watchdog =
|
|
options.ui || options.debug
|
|
? null
|
|
: executions.reduce(
|
|
(total, candidate) =>
|
|
total +
|
|
candidate.task.attemptTimeoutMs[candidate.environment.id] +
|
|
90_000,
|
|
0,
|
|
) +
|
|
5 * 60_000;
|
|
const processResult = await runProcess(
|
|
runnerE2EPlaywrightInvocation(repositoryRoot, playwrightArgs),
|
|
childEnv,
|
|
watchdog,
|
|
path.join(privateDir, "playwright.log"),
|
|
executions.map((candidate) =>
|
|
path.join(privateDir, "cases", candidate.task.id, "result.json"),
|
|
),
|
|
options.ui || options.debug,
|
|
);
|
|
processCleanupFailed = processResult.processCleanupError !== null;
|
|
if (options.installedStartupOnly) {
|
|
const proofDir = path.join(resultsRoot, campaignId, "installed-startup-only");
|
|
await mkdir(proofDir, { recursive: true });
|
|
// This is private setup evidence, never a successful provider case/result.
|
|
await cp(privateDir, path.join(proofDir, "private"), { recursive: true });
|
|
const probe = JSON.parse(await readFile(path.join(privateDir, "installed-startup-only.json"), "utf8"));
|
|
if (processResult.exitCode !== 0 || processResult.timedOut || processResult.spawnError || processCleanupFailed || probe.status !== "health_ui_zero_company_passed" || probe.providerCalls !== 0 || probe.qualified !== false) throw new Error("Installed startup-only actual launch chain failed");
|
|
await assertEmbeddedDatabaseIsolation(configPath, temporaryRoot);
|
|
startupReceiptPath = path.join(proofDir, "receipt.json");
|
|
await writeFile(startupReceiptPath, `${JSON.stringify({ status: "installed_launch_chain_pending_scratch_cleanup", qualified: false, providerCalls: 0, credentialsLoaded: false, installedCli, processResult, probe }, null, 2)}\n`, { mode: 0o600 });
|
|
return [];
|
|
}
|
|
const processFailure = processResult.spawnError
|
|
? `Playwright failed to start: ${processResult.spawnError}`
|
|
: processResult.timedOut
|
|
? `Harness process exceeded ${Math.round((watchdog ?? 0) / 1000)} seconds`
|
|
: `Playwright exited ${processResult.exitCode} before writing a result`;
|
|
const processFailureClass =
|
|
processResult.spawnError || processResult.timedOut
|
|
? "transient_infrastructure"
|
|
: classifyFailure(
|
|
new Error(
|
|
processResult.outputTail
|
|
? `${processFailure}\n${processResult.outputTail}`
|
|
: processFailure,
|
|
),
|
|
);
|
|
const results = await Promise.all(
|
|
executions.map(async (candidate) => {
|
|
const resultPath = path.join(
|
|
privateDir,
|
|
"cases",
|
|
candidate.task.id,
|
|
"result.json",
|
|
);
|
|
const fallback = syntheticResult(
|
|
candidate,
|
|
attempt,
|
|
startedAtMs,
|
|
processFailure,
|
|
processFailureClass,
|
|
);
|
|
const result = await readResult(resultPath, fallback);
|
|
if (!result.workerTiming) result.workerTiming = await recoverWorkerTiming(path.join(path.dirname(resultPath), "snapshots", "worker-timing.json"),
|
|
{ executionId: candidate.id, attempt, startedAt: result.startedAt, finishedAt: result.finishedAt });
|
|
if (result === fallback) {
|
|
result.feedback = await readRecoveredFeedback(path.dirname(resultPath), {
|
|
campaignId: accountingCampaignId, attemptId: accountingAttemptId, executionId: candidate.id,
|
|
});
|
|
if (traceConfig) result.tracing = await readInterruptedAttemptTrace(path.dirname(resultPath), {
|
|
projectId: traceConfig.projectId, executionId: candidate.id,
|
|
});
|
|
}
|
|
// Keep cleanup authority before evidence copying/publication can fail.
|
|
rawCleanupResults.push({ cleanup: result.cleanup, synthetic: result === fallback, status: result.status });
|
|
return enforceResultProcessIntegrity(result, processResult);
|
|
}),
|
|
);
|
|
let isolationError: unknown;
|
|
try {
|
|
await assertEmbeddedDatabaseIsolation(configPath, temporaryRoot);
|
|
} catch (error) {
|
|
isolationError = error;
|
|
}
|
|
let persistedStateError: unknown;
|
|
let persistedStateFinding: { file: string; stateArea: string; reason: string; matchCategory?: string } | undefined;
|
|
// Onboarding evaluates behavior; credential persistence belongs to a separate layer.
|
|
// Keep artifact redaction/publication checks independent of this filesystem scan.
|
|
const persistenceCheckedExecutions = executions.filter(
|
|
(candidate) => candidate.suite.id !== "first-task",
|
|
);
|
|
if (persistenceCheckedExecutions.length > 0) {
|
|
try {
|
|
const expectedEphemeralCredentials = new Set<string>();
|
|
for (const [label, directory] of [
|
|
["Paperclip home", paperclipHome],
|
|
["workspace", workspace],
|
|
] as const) {
|
|
while (true) {
|
|
// The managed Codex home may legitimately contain upstream source-code
|
|
// fixtures with fake `sk-*` strings. Reject exact campaign credentials.
|
|
const leak = await findSecretLeakInDirectory(directory, credentials, {
|
|
includeShapes: false,
|
|
sources: credentialSources,
|
|
ignoreFile: (file) => expectedEphemeralCredentials.has(file),
|
|
allowDisappearedFile: (file) =>
|
|
label === "Paperclip home" &&
|
|
isEphemeralPostgresScanFile(paperclipHome, file),
|
|
});
|
|
if (!leak) break;
|
|
const isManagedCodexRuntimeAuth =
|
|
label === "Paperclip home" &&
|
|
isEphemeralCodexRuntimeAuthFile(paperclipHome, leak.file);
|
|
if (isManagedCodexRuntimeAuth) {
|
|
const metadata = await lstat(leak.file);
|
|
if (metadata.isFile() && (metadata.mode & 0o777) === 0o600) {
|
|
// Codex CLI API-key mode requires this one runtime auth file. It
|
|
// lives only in the disposable cell root, is never published,
|
|
// must be owner-only, and is removed with the root below.
|
|
expectedEphemeralCredentials.add(leak.file);
|
|
continue;
|
|
}
|
|
}
|
|
persistedStateFinding = { ...leak, stateArea: label, file: path.relative(temporaryRoot, leak.file) };
|
|
throw new Error(`Secret leak in persisted ${label} state at ${persistedStateFinding.file}: ${leak.reason}${leak.matchCategory ? ` (${leak.matchCategory})` : ""}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
persistedStateError = error;
|
|
}
|
|
}
|
|
for (const [index, candidate] of executions.entries()) {
|
|
let result = {
|
|
...results[index],
|
|
accountingIdentity: { campaignId: accountingCampaignId, attemptId: accountingAttemptId },
|
|
};
|
|
if (
|
|
isolationError &&
|
|
result.failureClass !== "cleanup_failure" &&
|
|
result.failureClass !== "secret_leak"
|
|
) {
|
|
const isolationMessage =
|
|
isolationError instanceof Error
|
|
? isolationError.message
|
|
: String(isolationError);
|
|
const configWasUnavailableDuringTransientBootstrap =
|
|
result.failureClass === "transient_infrastructure" &&
|
|
typeof isolationError === "object" &&
|
|
isolationError !== null &&
|
|
"code" in isolationError &&
|
|
isolationError.code === "ENOENT";
|
|
result = {
|
|
...result,
|
|
status: "failed",
|
|
failureClass: configWasUnavailableDuringTransientBootstrap
|
|
? result.failureClass
|
|
: "permanent_infrastructure",
|
|
error: configWasUnavailableDuringTransientBootstrap
|
|
? `${result.error}; isolated config could not be inspected after bootstrap failure: ${isolationMessage}`
|
|
: isolationMessage,
|
|
};
|
|
}
|
|
if (persistedStateError && persistenceCheckedExecutions.includes(candidate)) {
|
|
const persistedStateMessage =
|
|
persistedStateError instanceof Error
|
|
? persistedStateError.message
|
|
: String(persistedStateError);
|
|
const persistedStateClass = classifyFailure(persistedStateError);
|
|
if (
|
|
persistedStateClass === "secret_leak" ||
|
|
(result.failureClass !== "secret_leak" &&
|
|
result.failureClass !== "cleanup_failure")
|
|
) {
|
|
result = {
|
|
...result,
|
|
status: "failed",
|
|
failureClass:
|
|
persistedStateClass === "secret_leak"
|
|
? "secret_leak"
|
|
: "permanent_infrastructure",
|
|
error: persistedStateMessage,
|
|
};
|
|
} else {
|
|
result = {
|
|
...result,
|
|
error: `${result.error}; persisted-state scan also failed: ${persistedStateMessage}`,
|
|
};
|
|
}
|
|
}
|
|
const casePrivateDir = path.join(privateDir, "cases", candidate.task.id);
|
|
const resultPath = path.join(casePrivateDir, "result.json");
|
|
const uploadDir = path.join(
|
|
resultsRoot,
|
|
campaignId,
|
|
candidate.suite.id,
|
|
candidate.profile.id,
|
|
candidate.environment.id,
|
|
candidate.task.id,
|
|
`attempt-${attempt}`,
|
|
);
|
|
await mkdir(casePrivateDir, { recursive: true });
|
|
await copySharedEvidence(privateDir, casePrivateDir);
|
|
if (persistedStateError && persistenceCheckedExecutions.includes(candidate)) {
|
|
const snapshots = path.join(casePrivateDir, "snapshots");
|
|
await mkdir(snapshots, { recursive: true });
|
|
const original = results[index];
|
|
await writeFile(path.join(snapshots, "persisted-state-scan.json"), `${JSON.stringify(secretScanFailureEvidence({
|
|
scanError: persistedStateError instanceof Error ? persistedStateError.message : String(persistedStateError),
|
|
finding: persistedStateFinding,
|
|
originalResult: { status: original.status, failureClass: original.failureClass, error: original.error },
|
|
}, credentials), null, 2)}\n`, { mode: 0o600 });
|
|
}
|
|
await writeFile(
|
|
resultPath,
|
|
`${JSON.stringify(sanitizeJson(result, credentials), null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
let evidence = await packageEvidence({
|
|
privateDir: casePrivateDir,
|
|
uploadDir,
|
|
secrets: credentials,
|
|
expectPassScreenshot: result.status === "passed",
|
|
});
|
|
if (evidence.leaks.length > 0 || evidence.missing.length > 0) {
|
|
result = {
|
|
...result,
|
|
status: "failed",
|
|
failureClass:
|
|
evidence.leaks.length > 0
|
|
? "secret_leak"
|
|
: "permanent_infrastructure",
|
|
error:
|
|
evidence.leaks.length > 0
|
|
? `Secret leak rejected from evidence: ${evidence.leaks.map((leak) => leak.file).join(", ")}`
|
|
: `Required evidence missing: ${evidence.missing.join(", ")}`,
|
|
};
|
|
await writeFile(
|
|
resultPath,
|
|
`${JSON.stringify(sanitizeJson(result, credentials), null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
evidence = await packageEvidence({
|
|
privateDir: casePrivateDir,
|
|
uploadDir,
|
|
secrets: credentials,
|
|
expectPassScreenshot: false,
|
|
});
|
|
}
|
|
console.log(
|
|
`${result.status === "passed" ? "PASS" : "FAIL"} ${candidate.id} attempt ${attempt} -> ${uploadDir}`,
|
|
);
|
|
publishedResults.push(result);
|
|
publishedResultPaths.set(
|
|
candidate.id,
|
|
path.join(uploadDir, "result.json"),
|
|
);
|
|
}
|
|
return [...publishedResults];
|
|
} finally {
|
|
let gatewayCleanupError: unknown;
|
|
try { await gateway?.close(); } catch (error) { gatewayCleanupError = error; }
|
|
if (!processCleanupFailed) reapNewDetachedDarwinSharedMemory(sharedMemoryBaseline);
|
|
let cleanupError: unknown;
|
|
const resourceAdmissionStarted = await access(path.join(temporaryRoot, "artifacts-private", "resource-admission-started"))
|
|
.then(() => true).catch((error: NodeJS.ErrnoException) => error.code !== "ENOENT");
|
|
if (mustPreserveRecoveryState({ processCleanupFailed, resourceAdmissionStarted, results: rawCleanupResults })) {
|
|
// Remote allocation cleanup is journaled in this instance's database.
|
|
// Deleting it after the controller exits would strand uncertain creates.
|
|
await chmod(temporaryRoot, 0o700);
|
|
cleanupError = new Error(`Preserving private recovery state after unconfirmed cleanup: ${temporaryRoot}`);
|
|
} else if (shouldKeepFailedDiagnostics({
|
|
enabled: process.env.PAPERCLIP_RUNNER_E2E_KEEP_FAILED_PRIVATE === "1",
|
|
expectedResults: executions.length,
|
|
results: publishedResults,
|
|
})) {
|
|
// Explicit diagnosis only. Confirmed resource cleanup stays confirmed;
|
|
// keep private traces for investigation without publishing provider data.
|
|
await chmod(temporaryRoot, 0o700);
|
|
console.warn(`Retained private failed-case diagnostics: ${temporaryRoot}`);
|
|
} else if (
|
|
temporaryRoot.startsWith(`${temporaryParent}${path.sep}paperclip-runner-e2e-`)
|
|
) {
|
|
for (let cleanupAttempt = 1; cleanupAttempt <= 3; cleanupAttempt += 1) {
|
|
try {
|
|
await rm(temporaryRoot, { recursive: true, force: true });
|
|
cleanupError = undefined;
|
|
break;
|
|
} catch (error) {
|
|
cleanupError = error;
|
|
if (cleanupAttempt < 3) {
|
|
await makeDirectoryTreeRemovable(temporaryRoot).catch(() => {});
|
|
await new Promise((resolve) =>
|
|
setTimeout(resolve, cleanupAttempt * 250),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
cleanupError = new Error(
|
|
`Refusing to remove unexpected temporary path ${temporaryRoot}`,
|
|
);
|
|
}
|
|
if (gatewayCleanupError) cleanupError = new AggregateError([cleanupError, gatewayCleanupError].filter(Boolean), "Eval gateway cleanup failed");
|
|
if (startupReceiptPath) {
|
|
const receipt = JSON.parse(await readFile(startupReceiptPath, "utf8"));
|
|
receipt.status = cleanupError ? "installed_launch_chain_cleanup_failed" : "installed_launch_chain_passed";
|
|
receipt.temporaryRootRemoved = !cleanupError;
|
|
await writeFile(startupReceiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
}
|
|
if (cleanupError) {
|
|
const message = `Temporary runner E2E state cleanup failed at ${temporaryRoot}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
|
|
for (const publishedResult of publishedResults) {
|
|
const publishedResultPath = publishedResultPaths.get(
|
|
publishedResult.executionId,
|
|
);
|
|
if (!publishedResultPath) continue;
|
|
Object.assign(publishedResult, {
|
|
status: "failed",
|
|
failureClass: "cleanup_failure",
|
|
error: publishedResult.error ? `${publishedResult.error}; ${message}` : message,
|
|
cleanup: "failed",
|
|
} satisfies Partial<RunnerE2EResult>);
|
|
const safeResult = `${JSON.stringify(
|
|
sanitizeJson(publishedResult, attemptSecrets),
|
|
null,
|
|
2,
|
|
)}\n`;
|
|
assertSecretFree(safeResult, attemptSecrets, publishedResultPath);
|
|
await writeFile(publishedResultPath, safeResult, "utf8");
|
|
}
|
|
// Cleanup failure is a failed cell, but must not suppress later cells in
|
|
// the same campaign. The result above carries the terminal failure.
|
|
console.error(message);
|
|
if (options.installedStartupOnly) throw new Error(message);
|
|
}
|
|
for (const result of publishedResults) {
|
|
const resultPath = publishedResultPaths.get(result.executionId);
|
|
const candidate = executions.find(candidate => candidate.id === result.executionId);
|
|
if (!traceConfig || !result.tracing || !resultPath || !candidate) continue;
|
|
const finalizationInput = {
|
|
receipt: result.tracing,
|
|
identity: { campaignId: accountingCampaignId, attemptId: accountingAttemptId, executionId: candidate.id,
|
|
provider: candidate.profile.provider, model: candidate.profile.model, environment: candidate.environment.id },
|
|
provenance: { suiteId: candidate.suite.id, suiteDefinitionHash: candidate.suiteDefinitionHash, profileId: candidate.profile.id,
|
|
caseId: candidate.task.id, sourceRevision: resolveRunnerE2ESource().sha },
|
|
result,
|
|
};
|
|
result.tracing.finalization = input.operations
|
|
? await finalizeEvalAttemptThroughRelay(traceConfig, finalizationInput,
|
|
path.join(`${input.operations.manifest.ledgerPath}.otlp`, `${createHash("sha256").update(accountingAttemptId).digest("hex")}-finalization.jsonl`))
|
|
: await finalizeEvalAttemptTrace(traceConfig, finalizationInput);
|
|
const safeResult = `${JSON.stringify(sanitizeJson(result, attemptSecrets), null, 2)}\n`;
|
|
assertSecretFree(safeResult, attemptSecrets, resultPath);
|
|
await writeFile(resultPath, safeResult, "utf8");
|
|
}
|
|
}
|
|
}
|
|
|
|
function printList(executions: readonly MatrixExecution[]) {
|
|
console.log("ID\tSUITE\tGENERATION\tPROVIDER\tMODEL\tCREDENTIALS");
|
|
for (const execution of executions) {
|
|
console.log(
|
|
[
|
|
execution.id,
|
|
execution.suite.id,
|
|
execution.profile.generation,
|
|
execution.profile.provider,
|
|
execution.profile.model,
|
|
execution.requiredCredentials.join(","),
|
|
].join("\t"),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function runExecutionWithRetry(input: {
|
|
execution: MatrixExecution;
|
|
campaignId: string;
|
|
options: ReturnType<typeof parseRunnerSelectors>;
|
|
onAttempt: (result: RunnerE2EResult) => void;
|
|
operations?: EvalOperations;
|
|
}): Promise<RunnerE2EResult> {
|
|
const { execution, campaignId, options } = input;
|
|
return executeWithAutomaticRetry({
|
|
task: execution.task, options,
|
|
qualificationCandidate: execution.profile.qualificationCandidate !== undefined,
|
|
cancelled: () => cancelled,
|
|
onRetry: result => console.warn(
|
|
`Retrying ${execution.id} in a fresh isolated harness after ${result.failureClass!.replaceAll("_", " ")}`,
|
|
),
|
|
runAttempt: async attempt => {
|
|
const [result] = await runAttempt({ executions: [execution], attempt, campaignId, options, operations: input.operations });
|
|
if (!result) throw new Error(`No result produced for ${execution.id} attempt ${attempt}`);
|
|
input.onAttempt(result);
|
|
return result;
|
|
},
|
|
});
|
|
}
|
|
|
|
async function runWithConcurrency<T, R>(
|
|
values: readonly T[],
|
|
concurrency: number,
|
|
worker: (value: T) => Promise<R>,
|
|
): Promise<R[]> {
|
|
const results = new Array<R>(values.length);
|
|
let cursor = 0;
|
|
const workers = Array.from(
|
|
{ length: Math.min(concurrency, values.length) },
|
|
async () => {
|
|
while (true) {
|
|
const index = cursor;
|
|
cursor += 1;
|
|
if (index >= values.length) return;
|
|
if (cancelled) throw new Error("Runner E2E campaign cancelled");
|
|
results[index] = await worker(values[index]!);
|
|
}
|
|
},
|
|
);
|
|
const settled = await Promise.allSettled(workers);
|
|
const failures = settled.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
|
if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Eval campaign workers failed");
|
|
return results;
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
if (args.includes("--target-config") || args.includes("product-journeys")) {
|
|
const { launchProductJourneys } = await import("./product-journeys/launch.js");
|
|
return launchProductJourneys(args, repositoryRoot);
|
|
}
|
|
let options: ReturnType<typeof parseRunnerSelectors>;
|
|
try {
|
|
options = parseRunnerSelectors(process.argv.slice(2));
|
|
} catch (error) {
|
|
if (error instanceof RunnerSelectorError)
|
|
throw new Error(`${error.message}\nUse --list to inspect valid cells.`);
|
|
throw error;
|
|
}
|
|
const executions = selectRunnerExecutions(options, runnerMatrix);
|
|
if (options.installedStartupOnly) {
|
|
assertInstalledStartupOnly(process.env, executions, options);
|
|
assertRunnerE2EPrerequisites(executions);
|
|
const campaignId = cleanId(process.env.PAPERCLIP_E2E_CAMPAIGN_ID ?? `installed-startup-${Date.now()}`);
|
|
await runAttempt({ executions, attempt: 1, campaignId, options });
|
|
console.log(`PASS installed launch chain (no provider qualification) -> ${path.join(resultsRoot, campaignId, "installed-startup-only")}`);
|
|
return;
|
|
}
|
|
if (options.list) {
|
|
printList(executions);
|
|
return;
|
|
}
|
|
if (options.matrixJson) {
|
|
const jobs = buildMatrixJobs(executions);
|
|
console.log(
|
|
JSON.stringify({
|
|
include: jobs,
|
|
needsDaytona: jobs.some((job) => job.needsDaytona),
|
|
executionIds: executions.map((execution) => execution.id),
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (process.env.PAPERCLIP_E2E_OPERATIONS_MANIFEST && executions.some(execution => execution.task.flow === "provider_connection")) throw new Error("Provider connection campaign has no budgeted routing qualification");
|
|
if (executions.some(execution => execution.task.flow === "provider_connection")) {
|
|
const { runConnectionCampaign } = await import("./connection-launch.js");
|
|
await runConnectionCampaign({ executions, catalog: runnerMatrix, configFile: options.connectionConfig, repositoryRoot });
|
|
return;
|
|
}
|
|
if (options.connectionConfig) throw new Error("--connection-config is only supported by the provider-connections suite");
|
|
|
|
// Keep admission before local-env loading and credential checks. Pending
|
|
// profiles remain discoverable, but cannot reach a provider.
|
|
assertRunnerE2EPrerequisites(executions);
|
|
assertNativeCompletionSelection(executions);
|
|
assertNativeInstructionSelection(executions);
|
|
const campaignId = cleanId(
|
|
process.env.PAPERCLIP_E2E_CAMPAIGN_ID ??
|
|
`local-${new Date().toISOString().replace(/[:.]/g, "-")}`,
|
|
);
|
|
const summaryDir = path.join(resultsRoot, campaignId);
|
|
await mkdir(summaryDir, { recursive: true });
|
|
if (executions.some(execution => execution.suite.id === "native-completion")) {
|
|
process.env[NATIVE_COMPLETION_PREFLIGHT_ENV] = prepareNativeCompletionPreflight(summaryDir);
|
|
}
|
|
if (executions.some(execution => execution.suite.id === NATIVE_INSTRUCTION_SUITE)) {
|
|
process.env[NATIVE_INSTRUCTION_PREFLIGHT_ENV] = prepareNativeInstructionPreflight(summaryDir);
|
|
}
|
|
if (executions.some(execution => execution.suite.id === "stock-harness")) {
|
|
process.env[STOCK_PREFLIGHT_ENV] = prepareStockHarnessPreflight(summaryDir);
|
|
}
|
|
|
|
await loadLocalEnvironment(process.env);
|
|
assertRemoteNativeEvidencePrerequisites(executions, process.env);
|
|
const missingCredentials = process.env.PAPERCLIP_E2E_OPERATIONS_MANIFEST ? [] : [
|
|
...new Set(
|
|
executions.flatMap((execution) => execution.requiredCredentials),
|
|
),
|
|
].filter((name) => !process.env[name]?.trim());
|
|
if (missingCredentials.length > 0) {
|
|
throw new Error(
|
|
`Missing runner E2E credentials: ${missingCredentials.join(", ")}`,
|
|
);
|
|
}
|
|
if (
|
|
executions.some((execution) => execution.environment.id === "daytona") &&
|
|
!isImmutableDaytonaImage(process.env.PAPERCLIP_E2E_DAYTONA_IMAGE)
|
|
) {
|
|
throw new Error(
|
|
"PAPERCLIP_E2E_DAYTONA_IMAGE must be an immutable image@sha256 digest for Daytona cells",
|
|
);
|
|
}
|
|
|
|
|
|
await writeFile(
|
|
path.join(summaryDir, "invocation-policy.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
version: 1,
|
|
maxAutomaticRetries: options.maxAutomaticRetries,
|
|
retryClasses: ["transient_infrastructure", "provider_variance"],
|
|
executions: executions.map(execution => ({
|
|
executionId: execution.id,
|
|
automaticRetryPolicy: execution.task.automaticRetryPolicy ?? "default",
|
|
maxAutomaticRetries: effectiveAutomaticRetryLimit(execution.task, options.maxAutomaticRetries),
|
|
})),
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
"utf8",
|
|
);
|
|
const requestedParallelism =
|
|
options.headed || options.ui || options.debug ? 1 : options.maxParallel;
|
|
console.log(
|
|
`Running ${executions.length} isolated execution(s) with max parallelism ${requestedParallelism}`,
|
|
);
|
|
const attempts: RunnerE2EResult[] = [];
|
|
const operations = process.env.PAPERCLIP_E2E_OPERATIONS_MANIFEST
|
|
? await openEvalOperations(process.env.PAPERCLIP_E2E_OPERATIONS_MANIFEST, executions, options.maxAutomaticRetries, process.env) : undefined;
|
|
let finalResults: RunnerE2EResult[] = [];
|
|
let campaignFailed = false;
|
|
try {
|
|
if (operations) await writeFile(path.join(summaryDir, "operations-preflight.json"), `${JSON.stringify(operations.manifest, null, 2)}\n`, { mode: 0o600 });
|
|
finalResults = await runWithConcurrency(executions, operations ? Math.min(requestedParallelism, operations.manifest.limits.maxConcurrent) : requestedParallelism,
|
|
execution => runExecutionWithRetry({ execution, campaignId, options, operations, onAttempt: result => attempts.push(result) }));
|
|
} catch {
|
|
campaignFailed = true;
|
|
finalResults = [...new Map(attempts.map(result => [result.executionId, result])).values()];
|
|
} finally {
|
|
try { await operations?.close(); } catch { campaignFailed = true; }
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString();
|
|
const operationsReport = operations ? await readEvalOperationsReport(operations.manifest.ledgerPath) : undefined;
|
|
if (operationsReport?.status === "unavailable") campaignFailed = true;
|
|
const campaign = buildRunnerCampaign({
|
|
campaignId,
|
|
generatedAt,
|
|
expected: executions.map((execution) => execution.id),
|
|
results: finalResults,
|
|
attempts,
|
|
...(operationsReport ? { operations: operationsReport } : {}),
|
|
});
|
|
const campaignSecrets = normalizedSecrets(
|
|
[...CREDENTIAL_NAMES, ...EVAL_OBSERVABILITY_SECRET_NAMES].map((name) => process.env[name]),
|
|
);
|
|
const safeCampaign = sanitizeJson(
|
|
campaign,
|
|
campaignSecrets,
|
|
) as typeof campaign;
|
|
const campaignText = `${JSON.stringify(safeCampaign, null, 2)}\n`;
|
|
assertSecretFree(campaignText, campaignSecrets, "campaign.json");
|
|
await writeFile(path.join(summaryDir, "campaign.json"), campaignText, "utf8");
|
|
const dashboard = renderRunnerE2EDashboard({
|
|
title: `Runner E2E · ${campaignId}`,
|
|
generatedAt,
|
|
expected: executions.map((execution) => execution.id),
|
|
catalog: runnerMatrix,
|
|
campaign: safeCampaign,
|
|
entries: safeCampaign.results.map((result) => ({
|
|
result,
|
|
valid: result.status === "passed" && result.cleanup === "passed",
|
|
errors:
|
|
result.status === "passed" && result.cleanup === "passed"
|
|
? []
|
|
: [
|
|
result.error ??
|
|
result.failureClass ??
|
|
`cleanup=${result.cleanup}`,
|
|
],
|
|
evidenceBaseHref: [
|
|
result.suiteId ?? "core-compatibility",
|
|
result.profileId,
|
|
result.environmentId,
|
|
result.caseId,
|
|
`attempt-${result.attempt}`,
|
|
].join("/"),
|
|
})),
|
|
});
|
|
assertSecretFree(dashboard, campaignSecrets, "dashboard.html");
|
|
await writeFile(path.join(summaryDir, "dashboard.html"), dashboard, "utf8");
|
|
console.log(
|
|
`Campaign ${campaignId}: ${safeCampaign.passed}/${safeCampaign.selected} passed`,
|
|
);
|
|
if (campaignFailed) console.error("Campaign finalization incomplete; inspect retained attempt artifacts and the operations ledger.");
|
|
if (campaignFailed || safeCampaign.failed > 0) process.exitCode = 1;
|
|
}
|
|
|
|
await main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|