mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10:00
Continue a released Runner after confirmed graceful eviction (#19760)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Core can release a completed Runner while its host retains the session. > - A host can later retire that Runner under its retention policy. > - Graceful retirement saves a suspended session before physical shutdown. > - A cached Core owner still expects the old physical task and rejects a new allocation. > - This pull request retires that dead local controller after verified stop. > - The benefit is ordinary session continuation for Codex and qualified Claude. ## Linked Issues or Issue Description Refs #19611, #19683, #19196, #19733 and #19157. After a host gracefully evicts a released task, the next run can receive a new allocation. A cached controller rejects it with `native_session_released_owner_scope_mismatch`. The detached-controller path can already recover the suspended session. ## What Changed - Require the exact source, old task and current account/workspace scope before releasing a cached controller. - Use the existing physical-stop receipt checks. Detach only the local controller, then use the same suspended-state admission as the detached path. - Preserve the current run's instruction reservation. Retain a failed detach for cleanup. - Add actual Core, PostgreSQL and native Runner tests for Codex and qualified Claude through ACPX. Route them once through the native CI lane. ## Verification - The original real-entry Codex case failed at the cached-task scope check after native retirement, confirmed process exit and a recorded stop receipt. - The current head's native CI lane passed all 16 eviction cases for Codex and qualified Claude. These check cached and detached owners, current instruction preparation, reused-allocation inspection, missing stop/state, foreign identity and non-suspended state. They verify the same provider session, two ordinary turns, one accepted source result and no replacement submit on refusal. - Local checks passed the composition matrix, 12 targeted executor controls, 43 CI lane-selection checks and server TypeScript. All eight refusal cases now assert the expected error and whether the checkpoint request or host read was reached. This addresses the first standard review's test finding. - Commands: `pnpm exec vitest run src/services/native-runtime/plugin-runner-eviction.integration.test.ts`, focused `native-session-executor.test.ts` controls, `node ../node_modules/typescript/bin/tsc --noEmit -p tsconfig.json`, and `node --test scripts/__tests__/run-vitest-stable-shard.test.mjs`. - [Full CI](https://github.com/paperclipai/paperclip/actions/runs/42349579107) passed on head `9a25709ef43365b1f1849413b935785838eca19b`. [Standard review](https://github.com/paperclipai/paperclip/pull/19760#issuecomment-6703629255) is 5/5 on this head, with no unresolved findings. Whitespace and added-line secret/PII checks passed. ## Risks - Controlled provider processes and an authenticated local host fixture model the Launcher edge. This does not qualify live vendor transcript storage, mounts or a deployment. - This covers graceful eviction before allocation or before stopped-task inspection during `describe`. Loss after that inspection or an ambiguous claim remains held for reconciliation. No unavailable reply proves physical stop. - Missing or invalid suspended state still refuses continuation. The deterministic refusal rule from #19733 remains unchanged. Abrupt death without a suspended checkpoint stays with the separate #19157 work. - No new capability, mode, schema, dependency, deployment or activation is added. The existing draft #19157 and dependent branches remain unchanged. ## Model Used OpenAI GPT-6.1 Sol (`gpt-6.1-sol`), high reasoning, through Codex with repository tools and code execution. The runtime does not expose a context-window size. ## 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 - [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 <[email protected]>
This commit is contained in:
1 parent
e95dcadbd2
commit
beb3e55072
5 files changed
+403
-3
No files matched your search
@@ -152,6 +152,16 @@ selection remains blocked until guarded retirement/recovery; it does not imply
|
||||
that a cold launch is safe. Confirmed claim or physical cleanup clears only the
|
||||
matching proof. Raw tickets are never stored in this record.
|
||||
|
||||
When graceful host eviction has saved a suspended session and Core has recorded
|
||||
physical stop, a later allocation can use ordinary `resumeFrom` recovery. Core
|
||||
checks the old task, source identity and current scope before detaching a cached
|
||||
released controller. Cached and detached paths then use the same retained-state
|
||||
checks. Missing state, a foreign identity or an unsettled checkpoint refuses
|
||||
continuation; Core does not replace the provider session on that refusal. This
|
||||
covers eviction before allocation and stopped-task inspection during `describe`.
|
||||
A later loss during claim still needs reconciliation. Abrupt process death without
|
||||
a suspended checkpoint remains a separate recovery path.
|
||||
|
||||
These Core paths do not advertise the capability or opt ordinary tasks into a
|
||||
private host channel. Existing durable lease and plugin-worker idle checks still
|
||||
apply; retaining a released Runner does not by itself make a tenant safe to sleep.
|
||||
|
||||
@@ -325,6 +325,7 @@ const usageDeliverySuitePath = "server/src/services/native-runtime/native-usage-
|
||||
const warmCheckpointOwnershipSuitePath = "server/src/services/native-runtime/native-warm-checkpoint-ownership.integration.test.ts";
|
||||
const pluginWarmOwnerSuitePath = "server/src/services/native-runtime/plugin-runner-task-warm.integration.test.ts";
|
||||
const pluginReleaseResumeSuitePath = "server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts";
|
||||
const pluginEvictionSuitePath = "server/src/services/native-runtime/plugin-runner-eviction.integration.test.ts";
|
||||
const dotRunnerSuitePath = "server/src/__tests__/dot-runner.test.ts";
|
||||
const restartRecoverySuitePath =
|
||||
"server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts";
|
||||
@@ -375,7 +376,8 @@ test("12 PR without-chat shards plus the dedicated chat and native-runner lanes
|
||||
assert.ok(!files.includes(warmCheckpointOwnershipSuitePath));
|
||||
assert.ok(!files.includes(pluginWarmOwnerSuitePath));
|
||||
assert.ok(!files.includes(pluginReleaseResumeSuitePath));
|
||||
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, ingressRetrySuitePath, dotRunnerSuitePath, restartRecoverySuitePath, ...restartWaitSuites, usageDeliverySuitePath, warmCheckpointOwnershipSuitePath, pluginWarmOwnerSuitePath, pluginReleaseResumeSuitePath, "server/src/services/native-runtime/plugin-runner-probe.test.ts"].sort(), full.selectedGeneralServerSuites.sort());
|
||||
assert.ok(!files.includes(pluginEvictionSuitePath));
|
||||
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, ingressRetrySuitePath, dotRunnerSuitePath, restartRecoverySuitePath, ...restartWaitSuites, usageDeliverySuitePath, warmCheckpointOwnershipSuitePath, pluginWarmOwnerSuitePath, pluginReleaseResumeSuitePath, pluginEvictionSuitePath, "server/src/services/native-runtime/plugin-runner-probe.test.ts"].sort(), full.selectedGeneralServerSuites.sort());
|
||||
assert.equal(new Set(files).size, files.length);
|
||||
const defaultRun = dryRunJson([], prEnv);
|
||||
assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount);
|
||||
@@ -398,6 +400,7 @@ for (const [caller, envOverrides] of [["Release", { GITHUB_WORKFLOW: "Release" }
|
||||
assert.ok(files.includes(warmCheckpointOwnershipSuitePath));
|
||||
assert.ok(files.includes(pluginWarmOwnerSuitePath));
|
||||
assert.ok(files.includes(pluginReleaseResumeSuitePath));
|
||||
assert.ok(files.includes(pluginEvictionSuitePath));
|
||||
assert.deepEqual([...files, chatSuitePath].sort(), full.selectedGeneralServerSuites.sort());
|
||||
assert.equal(new Set(files).size, files.length);
|
||||
});
|
||||
@@ -433,6 +436,7 @@ test("the native-runner lane runs exactly the cargo-dependent suites", () => {
|
||||
warmCheckpointOwnershipSuitePath,
|
||||
pluginWarmOwnerSuitePath,
|
||||
pluginReleaseResumeSuitePath,
|
||||
pluginEvictionSuitePath,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ const nativeRunnerSuites = [
|
||||
"server/src/services/native-runtime/native-warm-checkpoint-ownership.integration.test.ts",
|
||||
"server/src/services/native-runtime/plugin-runner-task-warm.integration.test.ts",
|
||||
"server/src/services/native-runtime/plugin-runner-release-resume.integration.test.ts",
|
||||
"server/src/services/native-runtime/plugin-runner-eviction.integration.test.ts",
|
||||
];
|
||||
// In the PR workflow (pr.yml, the caller of pr-trusted.yml — reusable
|
||||
// workflows inherit the caller's GITHUB_WORKFLOW), the last Verify Paperclip
|
||||
|
||||
@@ -9510,8 +9510,39 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
let retainedTransition: VerifiedWarmTransitionBinding | undefined;
|
||||
let remoteWarmRecovery: RemoteWarmSessionRecovery | undefined;
|
||||
const cachedReleasedOwner = warmNativeSessions.get(nativeSessionScopeKey(input.execution));
|
||||
if (cachedReleasedOwner?.released) requireReleasedWarmScope(input, releasedWarmRunner(readWarmNativeEnvelope(input.execution)),
|
||||
readRunnerdDurableIdentity(scopedRunnerdStateRoot(input.execution)));
|
||||
if (cachedReleasedOwner?.released) {
|
||||
const proof = releasedWarmRunner(readWarmNativeEnvelope(input.execution));
|
||||
const identity = readRunnerdDurableIdentity(scopedRunnerdStateRoot(input.execution));
|
||||
const target = input.runnerExecutionTarget;
|
||||
if (proof && target?.kind === "remote" && target.transport === "sandbox" && target.runnerTask && target.leaseId
|
||||
&& target.sandboxLeaseAcquisition?.outcome === "created" && proof.taskId !== target.sandboxLeaseAcquisition.providerLeaseId) {
|
||||
const ownInstructionPreparation = cachedReleasedOwner.instructionPreparationRunId === input.execution.binding.runId
|
||||
&& input.instructionWorkingCopy?.runId === input.execution.binding.runId;
|
||||
if ((cachedReleasedOwner.busy && !ownInstructionPreparation)
|
||||
|| cachedReleasedOwner.preparingRunId && cachedReleasedOwner.preparingRunId !== input.execution.binding.runId
|
||||
|| cachedReleasedOwner.instructionPreparationRunId && !ownInstructionPreparation
|
||||
|| cachedReleasedOwner.closeOnReleaseReason) throw new Error("native_session_supervisor_busy");
|
||||
if (proof.taskId !== cachedReleasedOwner.pluginTaskId || proof.scopeDigest !== releasedWarmScope(input)
|
||||
|| canonicalJson(proof.source) !== canonicalJson(identity)) throw new Error("native_session_released_owner_scope_mismatch");
|
||||
const wasBusy = cachedReleasedOwner.busy;
|
||||
cachedReleasedOwner.busy = true;
|
||||
try {
|
||||
// Only physical stop releases the cached controller; ordinary checkpoint admission still proves continuation.
|
||||
const resumeFrom = await resolvePluginTaskResume(input.db, { companyId: input.execution.binding.companyId,
|
||||
leaseId: target.leaseId, priorIdentity: proof.source, inspectCheckpoint: true });
|
||||
if (resumeFrom !== proof.taskId) throw new Error("native_session_released_owner_scope_mismatch");
|
||||
if (!cachedReleasedOwner.session.detachControllerForRestart) throw new Error("native_session_released_detach_unavailable");
|
||||
try {
|
||||
await cachedReleasedOwner.session.detachControllerForRestart();
|
||||
await cachedReleasedOwner.githubAccess?.stop();
|
||||
} catch (error) {
|
||||
cachedReleasedOwner.closeOnReleaseReason = "released Runner local detach is unconfirmed";
|
||||
throw error;
|
||||
}
|
||||
forgetWarmNativeSession(nativeSessionScopeKey(input.execution), cachedReleasedOwner);
|
||||
} finally { cachedReleasedOwner.busy = wasBusy; }
|
||||
} else requireReleasedWarmScope(input, proof, identity);
|
||||
}
|
||||
if (input.useRunnerd && !input.restartRecovery && !input.backend &&
|
||||
input.runnerExecutionTarget?.kind === "remote" &&
|
||||
input.execution.session.lifecyclePolicy.mode === "warm" &&
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, cp, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
||||
import https, { createServer } from "node:https";
|
||||
import { createServer as createTcpServer } from "node:net";
|
||||
import { join, resolve } from "node:path";
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls";
|
||||
import { eq } from "drizzle-orm";
|
||||
import WebSocket, { WebSocketServer, type RawData } from "ws";
|
||||
import { expect, it } from "vitest";
|
||||
import { completionContracts, environmentLeases, environments, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, plugins } from "@paperclipai/db";
|
||||
import { environmentTaskOperationSchema, type PluginEnvironmentTaskOperation } from "@paperclipai/plugin-sdk";
|
||||
import { readRunnerdArtifactBinding, defaultCapabilityRunnerdBinary,
|
||||
parseNativeExecutionInput, parseNativeRuntimeContext, QUALIFIED_ACPX_PROFILES, type DurableRecoveryIdentity, type RunnerProcessHandle, type RunnerProcessLaunchSpec } from "../../vendor/paperclip-runner/index.js";
|
||||
import { createRunnerProviderLaunch, runnerLaunchRequirementsDigest, canonicalNativeRuntimeContextDigest } from "@paperclipai/paperclip-runner";
|
||||
import { materializeAsset } from "./runtime-context.js";
|
||||
import { spawnRunner } from "../../vendor/paperclip-runner/testing.js";
|
||||
import { describeEmbeddedPostgres, nativeRestartFixture, realProcessIt, fakeCodexAppServer, waitForCondition } from "../../__tests__/helpers/native-restart.js";
|
||||
import { executePaperclipNativeSession, closeIdleWarmNativeSessionsForRestart, readNativeSessionIdleWork, claimWarmNativeInstructionCopy } from "./native-session-executor.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { environmentRuntimeService } from "../environment-runtime.js";
|
||||
import { environmentService } from "../environments.js";
|
||||
import { resolveEnvironmentExecutionTarget } from "../environment-execution-target.js";
|
||||
import { executeEnvironmentTask } from "../environment-task-runtime.js";
|
||||
import type { PluginWorkerManager } from "../plugin-worker-manager.js";
|
||||
import { reconcileNativeFinalizations } from "./native-finalization-reconciler.js";
|
||||
import { buildNativeCompletionContract } from "./completion-contracts.js";
|
||||
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
|
||||
|
||||
const { createPrivateHostControlFixture } = await import(new URL("../../../../packages/paperclip-runner/test/support/private-host-control.ts", import.meta.url).href) as {
|
||||
createPrivateHostControlFixture(): {
|
||||
launch(spec: RunnerProcessLaunchSpec): RunnerProcessHandle;
|
||||
request(frame: Record<string, unknown>): Promise<{ id: string; status: string }>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
const fakeAcpxSidecar = resolve(import.meta.dirname, "../../../../packages/paperclip-runner/runner/target/debug/fake-acpx-sidecar");
|
||||
const sha = (value: string) => createHash("sha256").update(value).digest("hex");
|
||||
const parkId = (source: DurableRecoveryIdentity) => `park_${sha(JSON.stringify(Object.fromEntries(Object.entries(source).sort(([a], [b]) => a.localeCompare(b)))))}`;
|
||||
|
||||
// The only replaced service is the external environment-task/Launcher edge.
|
||||
// Runtime, backend, owner, PRP ingress, checkpoint persistence and native Runner are real.
|
||||
describeEmbeddedPostgres("plugin Runner graceful eviction through the Core execution entry", () => {
|
||||
const environment = nativeRestartFixture();
|
||||
const nativeIt = process.platform === "win32" ? it.skip : realProcessIt;
|
||||
nativeIt.for([...(["codex", "claude"] as const).flatMap(harness => (["cached", "detached", "reused lease", "prepared copy", "missing stop", "missing state", "foreign state", "working state"] as const).map(mode => [harness, mode] as const))])("continues %s after graceful eviction with a %s Core owner", { timeout: 60_000 }, async ([harness, mode], context) => {
|
||||
if (harness === "claude" && !existsSync(fakeAcpxSidecar)) context.skip();
|
||||
const detached = mode === "detached" || mode === "missing state" || mode === "foreign state";
|
||||
const denied = mode.startsWith("missing") || mode.endsWith("state");
|
||||
const instructionCopy = (id: string) => ({ runId: id, root: workspace, preparationKey: "unchanged-fixture-instructions", hasChanges: async () => false, collectStopped: async () => {} });
|
||||
const first = await environment.seedRun(`RELEASE-ENTRY-${harness}-${mode}`);
|
||||
const { db, runId, issueId, native } = first;
|
||||
const { companyId, agentId, runtimeRoot } = environment.current;
|
||||
const root = join(runtimeRoot, runId), workspace = join(root, "workspace"), home = join(root, "credential-home");
|
||||
await mkdir(workspace, { recursive: true }); await mkdir(home, { recursive: true });
|
||||
await writeFile(join(home, "auth.json"), JSON.stringify({ OPENAI_API_KEY: "synthetic-fixture" }), { mode: 0o600 });
|
||||
const workspaceId = randomUUID(), environmentId = randomUUID(), leaseA = randomUUID(), taskA = randomUUID(), taskB = randomUUID(), pluginId = randomUUID();
|
||||
const providerKey = `plugin:${pluginId}:tasks`;
|
||||
const metadata = { driver: "plugin", pluginId, driverKey: "tasks", runnerTaskLease: true };
|
||||
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "eviction-fixture", version: "1.0.0", status: "ready", manifestJson: { capabilities: ["environment.drivers.register"], environmentDrivers: [{ driverKey: "tasks", supportsTasks: true }] } as never });
|
||||
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, sourceIssueId: issueId,
|
||||
mode: "isolated", strategyType: "directory", name: "Release fixture", cwd: workspace });
|
||||
await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "plugin", config: { pluginKey: pluginId, driverKey: "tasks" } });
|
||||
await db.insert(environmentLeases).values({ id: leaseA, companyId, environmentId, heartbeatRunId: runId, issueId,
|
||||
executionWorkspaceId: workspaceId, provider: providerKey, providerLeaseId: taskA, status: "active", leasePolicy: "reuse_by_environment", metadata: { ...metadata, remoteCwd: workspace } });
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const [contract] = await db.select().from(completionContracts).where(eq(completionContracts.issueId, issueId));
|
||||
const runtimeContext = nativeRuntimeContextFixture();
|
||||
runtimeContext.instructions.bundle = await materializeAsset([{ path: "AGENTS.md", mode: 0o444, content: Buffer.from("Complete only the controlled task.\n") }]);
|
||||
runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(runtimeContext);
|
||||
const { permissionPolicy: _permissionPolicy, ...claudeProfile } = QUALIFIED_ACPX_PROFILES.claude;
|
||||
const execution = parseNativeExecutionInput({ schema: "paperclip.native-execution-input.v5", executionMode: "default", planningContext: null,
|
||||
runtimeContext, binding: { companyId, agentId, issueId, runId, executionWorkspaceId: workspaceId },
|
||||
provider: harness === "codex" ? { kind: "codex", model: "fixture-model", approvalPolicy: "never" }
|
||||
: { kind: "acpx", agent: "claude", model: "claude-sonnet-4-6", permissionMode: "approve-all", profile: claudeProfile },
|
||||
task: { identifier: "RELEASE-ENTRY", title: issue!.title, description: null, prompt: "Complete the controlled task.", workMode: "standard" },
|
||||
workspace: { cwd: workspace, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: native.normalizedSessionId, driverKind: harness === "codex" ? "codex_app_server" : "acpx_runtime", protocolVersion: 1,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: detached ? 5_000 : 60_000 } },
|
||||
completionContract: { id: contract!.id, sha256: contract!.canonicalSha256, schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: buildNativeCompletionContract(issue!, { revision: contract!.revision }) }, interactionResponses: [], credentialBindings: [] });
|
||||
const resultFile = join(root, "result.json"), calls = join(root, "provider-calls");
|
||||
await writeFile(resultFile, JSON.stringify({ schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Controlled task complete.",
|
||||
completionClaim: { contractRevision: execution.completionContract.contract.revision, objectiveSatisfied: true,
|
||||
criteria: execution.completionContract.contract.criteria.map(c => ({ criterionId: c.id, status: "satisfied", evidenceRefs: [] })), remainingWork: [] },
|
||||
evidence: [], verification: [], artifacts: [], attentionRequests: [] }));
|
||||
const sidecar = join(root, "sidecar"), sidecarScript = join(root, "sidecar-entry");
|
||||
if (harness === "claude") {
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
await writeFile(sidecarScript, "qualified fixture entry");
|
||||
await writeFile(sidecar, `#!/bin/sh\nexec ${quote(fakeAcpxSidecar)} --mode source-reconcile --profile-digest ${quote(claudeProfile.commandDigest)} --journal ${quote(calls)} --source-fault ${quote(join(root, "fault"))} --wait-completion-feedback\n`, { mode: 0o700 });
|
||||
}
|
||||
const key = join(root, "tls.key"), certificate = join(root, "tls.crt");
|
||||
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key, "-out", certificate,
|
||||
"-days", "1", "-subj", "/CN=localhost", "-addext", "subjectAltName=IP:127.0.0.1,DNS:localhost"], { stdio: "ignore" });
|
||||
const oldCAs = getCACertificates("default"), oldAgent = https.globalAgent;
|
||||
const certificates = [...oldCAs, await readFile(certificate, "utf8")];
|
||||
const probe = createTcpServer(); await new Promise<void>(done => probe.listen(0, "127.0.0.1", done));
|
||||
const address = probe.address(); if (!address || typeof address === "string") throw new Error("Fixture listener unavailable");
|
||||
const runnerPort = address.port; await new Promise<void>(done => probe.close(() => done()));
|
||||
const tls = createServer({ key: await readFile(key), cert: await readFile(certificate) });
|
||||
const sockets = new Set<WebSocket>(), websocket = new WebSocketServer({ noServer: true });
|
||||
let listenPath = "";
|
||||
const wireEvents: string[] = [];
|
||||
tls.on("upgrade", (request, socket, head) => {
|
||||
if (request.headers["x-fixture-token"] !== "synthetic-ingress") { socket.destroy(); return; }
|
||||
websocket.handleUpgrade(request, socket, head, front => {
|
||||
const back = new WebSocket(`ws://127.0.0.1:${runnerPort}${listenPath}`, { perMessageDeflate: false });
|
||||
const pending: Array<{ data: RawData; binary: boolean }> = []; sockets.add(front); sockets.add(back);
|
||||
front.on("message", (data, binary) => {
|
||||
if (!binary) { const frame = JSON.parse(data.toString()); wireEvents.push(`core:${frame.kind ?? frame.type}:${frame.payload?.type ?? frame.payload?.eventType ?? frame.error?.code ?? ""}`); }
|
||||
if (back.readyState === WebSocket.OPEN) back.send(data, { binary }); else pending.push({ data, binary });
|
||||
});
|
||||
back.on("open", () => { for (const message of pending.splice(0)) back.send(message.data, { binary: message.binary }); });
|
||||
back.on("message", (data, binary) => {
|
||||
if (!binary) { const frame = JSON.parse(data.toString()); wireEvents.push(`runner:${frame.kind ?? frame.type}:${frame.payload?.type ?? frame.payload?.eventType ?? frame.error?.code ?? ""}`); }
|
||||
if (front.readyState === WebSocket.OPEN) front.send(data, { binary });
|
||||
});
|
||||
front.on("close", () => { sockets.delete(front); back.terminate(); }); back.on("close", () => { sockets.delete(back); front.terminate(); });
|
||||
front.on("error", () => back.terminate()); back.on("error", () => front.terminate());
|
||||
});
|
||||
});
|
||||
await new Promise<void>(done => tls.listen(0, "127.0.0.1", done));
|
||||
const tlsAddress = tls.address(); if (!tlsAddress || typeof tlsAddress === "string") throw new Error("Fixture TLS unavailable");
|
||||
const hosts = new Map<string, ReturnType<typeof createPrivateHostControlFixture>>([[taskA, createPrivateHostControlFixture()], [taskB, createPrivateHostControlFixture()]]);
|
||||
const binary = defaultCapabilityRunnerdBinary();
|
||||
const handles = new Map<string, RunnerProcessHandle>();
|
||||
const runnerRoot = (taskId: string) => join(root, taskId);
|
||||
const artifact = readRunnerdArtifactBinding(binary), preparations = new Map<string, { operation: Extract<PluginEnvironmentTaskOperation, { kind: "prepare" }>; launch: { configurationId: string } }>();
|
||||
const releases: DurableRecoveryIdentity[] = [], claims: DurableRecoveryIdentity[] = [];
|
||||
let handle: RunnerProcessHandle | undefined, submissions = 0, cleaning = false, sourceReadRequests = 0, sourceReadResponses = 0;
|
||||
let pendingFollowup: Promise<unknown> | undefined;
|
||||
const providerStarts = async () => (await readFile(calls, "utf8")).trim().split(/\r?\n/)
|
||||
.filter(line => harness === "codex" ? line === "process-start" : JSON.parse(line).processStart).length;
|
||||
const state = async (taskId: string = taskA) => JSON.parse(await readFile(join(runnerRoot(taskId), "runner-state.json"), "utf8"));
|
||||
const sameSource = (a: DurableRecoveryIdentity, b: DurableRecoveryIdentity) => Object.entries(a).every(([key, value]) => b[key as keyof DurableRecoveryIdentity] === value);
|
||||
const task = async (raw: unknown, authorityRunId: string, taskId: string): Promise<unknown> => {
|
||||
const host = hosts.get(taskId)!;
|
||||
const operation = environmentTaskOperationSchema.parse(raw);
|
||||
if (operation.kind === "describe") return { kind: "description", taskId, launchPreparationVersion: 1,
|
||||
projectAuthCheckVersion: 1, warmTaskVersion: 1, releaseResumeVersion: 1, runner: artifact };
|
||||
if (operation.kind === "prepare") {
|
||||
expect(operation.requirements.identity.runId).toBe(authorityRunId);
|
||||
expect(operation.accountIdentity).toBe("fixture-account");
|
||||
if (operation.priorIdentity) expect(sameSource(operation.priorIdentity, await state(taskId))).toBe(true);
|
||||
expect(operation.requirements.settings.managedProfile).toBeUndefined();
|
||||
expect(operation.requirements.settings.agentCoreProfile).toBeUndefined();
|
||||
expect(operation.requirements.settings.acpxAgent).toBe(harness === "claude" ? "claude" : undefined);
|
||||
const requirements = { ...operation.requirements, runtimeContext: operation.requirements.runtimeContext ? parseNativeRuntimeContext(operation.requirements.runtimeContext) : null, settings: { ...operation.requirements.settings, acpxAgent: harness === "claude" ? "claude" as const : undefined, managedProfile: undefined, agentCoreProfile: undefined } };
|
||||
const launch = { configurationId: `configuration-${operation.requirements.identity.runId}`, requirementsDigest: runnerLaunchRequirementsDigest(requirements),
|
||||
provider: createRunnerProviderLaunch(requirements, { options: { codexArgs: ["--state-file", join(root, "provider.json"), "--terminal-result-file", resultFile,
|
||||
"--call-log", calls, "--record-process-start", "--durable-turn-ids"] }, root, cwd: workspace, codexHome: home, codexCommand: fakeCodexAppServer,
|
||||
providerNodeCommand: harness === "claude" ? sidecar : process.execPath, opencodeProxyPath: "", acpxSidecarPath: harness === "claude" ? sidecarScript : "" }) };
|
||||
preparations.set(launch.configurationId, { operation, launch }); return { kind: "prepared", taskId, launch };
|
||||
}
|
||||
if (operation.kind === "check_project_auth") return { kind: "project_auth", taskId, configurationId: operation.configurationId, provider: operation.provider, allowed: true };
|
||||
if (operation.kind === "submit") {
|
||||
if (!operation.preparedLaunchId || !operation.launch) throw new Error("Fixture structured launch unavailable");
|
||||
const prepared = preparations.get(operation.preparedLaunchId)!; submissions++;
|
||||
if (operation.resumeFrom) {
|
||||
expect(operation.resumeFrom).toBe(taskA);
|
||||
expect(handles.get(taskA)?.isExitConfirmed?.()).toBe(true);
|
||||
await cp(runnerRoot(taskA), runnerRoot(taskId), { recursive: true });
|
||||
}
|
||||
expect(operation.runner).toMatchObject({ runnerId: prepared.operation.requirements.identity.runnerInstanceId,
|
||||
leaseId: prepared.operation.requirements.identity.environmentLeaseId, runId: authorityRunId,
|
||||
sessionId: prepared.operation.requirements.identity.normalizedSessionId, turnId: prepared.operation.requirements.identity.turnId,
|
||||
itemId: prepared.operation.requirements.identity.itemId });
|
||||
listenPath = `/api/runner/v1/connect/${operation.runner.runId}`;
|
||||
handle = spawnRunner({ ...operation.launch, stateDirectory: runnerRoot(taskId), identity: prepared.operation.requirements.identity, ticket: operation.bootstrapTicket,
|
||||
runnerBinaryPath: binary, maxOutboxBytes: operation.launch.maxOutboxBytes, p0ReserveBytes: operation.launch.p0ReserveBytes,
|
||||
connection: { mode: "listen", listenAddress: "0.0.0.0", listenPort: runnerPort, listenPath }, processLauncher: host.launch,
|
||||
environment: { PATH: process.env.PATH },
|
||||
...(harness === "claude" ? { acpxLaunchProfile: { authorityDigest: `sha256:${sha("fixture authority")}`, command: sidecar,
|
||||
commandSha256: `sha256:${sha(await readFile(sidecar, "utf8"))}`, sidecarScript,
|
||||
sidecarScriptSha256: `sha256:${sha(await readFile(sidecarScript, "utf8"))}` } } : {}) });
|
||||
handles.set(taskId, handle);
|
||||
return { kind: "accepted", taskId };
|
||||
}
|
||||
if (operation.kind === "connection") return { kind: "connection", taskId, endpoint: { kind: "authenticated_websocket",
|
||||
websocketUrl: `wss://127.0.0.1:${tlsAddress.port}/runner`, secretHeaders: [{ name: "X-Fixture-Token", value: "synthetic-ingress" }], generation: "fixture" } };
|
||||
if (operation.kind === "status") return { kind: "status", taskId, phase: handles.get(taskId)?.isExitConfirmed?.() ? "cancelled" : "running", executionStopped: (cleaning || mode !== "missing stop") && (handles.get(taskId)?.isExitConfirmed?.() ?? false), sessionStateAvailable: (cleaning || mode !== "missing state" && mode !== "missing stop") && handles.get(taskId)?.isExitConfirmed?.() === true && (await state(taskId)).lifecycle === "suspended" };
|
||||
if (operation.kind === "read_state") {
|
||||
if (operation.resumeFrom === taskA) sourceReadResponses++;
|
||||
const live = await state(operation.resumeFrom ?? taskId);
|
||||
const previous = [...preparations.values()].some(({ operation: prepared }) => prepared.priorIdentity && sameSource(prepared.priorIdentity, operation.identity));
|
||||
expect(sameSource(operation.identity, live) || previous).toBe(true);
|
||||
return { kind: "state", taskId, state: operation.resumeFrom && mode === "foreign state" ? { ...live, turnId: "foreign-turn" }
|
||||
: operation.resumeFrom && mode === "working state" ? { ...live, lifecycle: "ready" } : live };
|
||||
}
|
||||
if (operation.kind === "archive_state") {
|
||||
expect(operation.resumeFrom).toBe(taskA);
|
||||
expect(handles.get(taskA)?.isExitConfirmed?.()).toBe(true);
|
||||
const path = join(runnerRoot(taskA), "runner-state.json"), archived = join(runnerRoot(taskA), `archive-${operation.archiveKey}.json`);
|
||||
try { await rename(path, archived); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
const saved = JSON.parse(await readFile(archived, "utf8"));
|
||||
expect(sameSource(operation.priorIdentity, saved)).toBe(true);
|
||||
return { kind: "state", taskId, state: saved };
|
||||
}
|
||||
if (operation.kind === "release") {
|
||||
const live = await state(taskId); const reply = await host.request({ op: "park", id: parkId(operation.identity), identity: operation.identity,
|
||||
lastControllerCommandSeq: live.lastControllerCommandSeq, ackedSourceSeq: live.ackedSourceSeq });
|
||||
if (reply.status !== "parked") throw new Error("Fixture native release refused");
|
||||
releases.push(operation.identity); return { kind: "released", taskId };
|
||||
}
|
||||
if (operation.kind === "resume") {
|
||||
const retainedProviderCount = await providerStarts();
|
||||
const reply = await host.request({ op: "claim", id: `claim_${sha(operation.bootstrapTicket)}`, parkId: parkId(operation.identity), bootstrapTicket: operation.bootstrapTicket });
|
||||
if (reply.status !== "claimed") throw new Error("Fixture native claim refused");
|
||||
expect(await providerStarts()).toBe(retainedProviderCount);
|
||||
claims.push(operation.identity);
|
||||
return { kind: "resume", taskId, outcome: "resumed" };
|
||||
}
|
||||
if (operation.kind === "stop") { await host.close(); }
|
||||
if (operation.kind === "complete") { expect(handles.get(taskId)?.isExitConfirmed?.()).toBe(true); }
|
||||
return { kind: "accepted", taskId };
|
||||
};
|
||||
const supervisor = async () => {
|
||||
const directory = join(resolvePaperclipInstanceRoot(), "runtime/paperclip-runner/sessions");
|
||||
for (const file of await readdir(directory)) {
|
||||
const envelope = JSON.parse(await readFile(join(directory, file), "utf8"));
|
||||
if (envelope.snapshot?.identity.sessionId === native.normalizedSessionId) return envelope;
|
||||
}
|
||||
throw new Error("Fixture supervisor record unavailable");
|
||||
};
|
||||
const proof = async () => (await supervisor()).releasedRunner;
|
||||
const workers = { getWorker: () => ({ supportedMethods: ["environmentTask"] }), call: async (_id: string, method: string, params: any, _timeout?: number, _unused?: unknown, settled?: () => void) => {
|
||||
if (method === "environmentAcquireLease") return { providerLeaseId: taskB, metadata: { creationOperationId: params.creationOperationId } };
|
||||
try { return await task(params.operation, params.runId, params.taskId); } finally { settled?.(); }
|
||||
} } as unknown as PluginWorkerManager;
|
||||
const dispatch = (leaseId: string, operation: PluginEnvironmentTaskOperation, options?: { executionContext?: unknown; expectedTaskId?: string }) => {
|
||||
if (operation.kind === "read_state" && operation.resumeFrom === taskA) sourceReadRequests++;
|
||||
return executeEnvironmentTask(db, workers, { companyId, leaseId, operation, ...options });
|
||||
};
|
||||
const execute = async (current: typeof execution, leaseId: string) => {
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeExecutionInput: current }, runnerInstanceId: native.runnerInstanceId, completionContractId: contract!.id, completionContractSha256: contract!.canonicalSha256, nativeSessionId: current.session.normalizedSessionId })
|
||||
.where(eq(heartbeatRuns.id, current.binding.runId));
|
||||
const target = mode === "reused lease" ? await resolveEnvironmentExecutionTarget({ db, companyId, adapterType: "paperclip_runner",
|
||||
environment: { id: environmentId, driver: "plugin", config: { pluginKey: pluginId, driverKey: "tasks" } }, leaseId,
|
||||
lease: await environmentService(db).getLeaseById(leaseId), leaseMetadata: metadata,
|
||||
environmentRuntime: environmentRuntimeService(db, { pluginWorkerManager: workers }) }) : {
|
||||
kind: "remote" as const, transport: "sandbox" as const, remoteCwd: workspace, environmentId, leaseId, providerKey,
|
||||
runnerTask: (operation: unknown, options?: { executionContext?: unknown; expectedTaskId?: string }) => dispatch(leaseId, operation as PluginEnvironmentTaskOperation, options),
|
||||
sandboxLeaseAcquisition: { outcome: "created" as const, providerLeaseId: leaseId === leaseA ? taskA : taskB } };
|
||||
if (!target) throw new Error("Fixture execution target unavailable");
|
||||
return executePaperclipNativeSession({ db, execution: current, runnerInstanceId: native.runnerInstanceId, useRunnerd: true, runnerIngressAuthorized: true,
|
||||
runnerEnvironment: harness === "claude" ? { ANTHROPIC_API_KEY: "synthetic-fixture" } : {},
|
||||
...(mode === "prepared copy" ? { instructionWorkingCopy: instructionCopy(current.binding.runId) } : {}),
|
||||
managedAiCredentialHome: home, managedAiCredentialIdentity: "fixture-revision", managedAiAccountIdentity: "fixture-account", turnTimeoutMs: 15_000,
|
||||
runnerExecutionTarget: target });
|
||||
};
|
||||
try {
|
||||
setDefaultCACertificates(certificates); https.globalAgent = new https.Agent({ ca: certificates });
|
||||
const a = await execute(execution, leaseA); expect(a).toMatchObject({ exitCode: 0, timedOut: false });
|
||||
await waitForCondition("automatic release A", () => releases.length === 1);
|
||||
expect((await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId)))[0]).toMatchObject({ schemaStatus: "accepted", resultJson: { result: { reportedWorkDisposition: "done" } } });
|
||||
expect(await proof()).toMatchObject({ source: releases[0], taskId: taskA });
|
||||
const providerSessionId = (await supervisor()).snapshot.providerSessionId;
|
||||
expect(providerSessionId).toEqual(expect.any(String));
|
||||
if (detached) await new Promise(done => setTimeout(done, 5_200));
|
||||
expect(readNativeSessionIdleWork()).toBe("none");
|
||||
await reconcileNativeFinalizations(db, [runId]);
|
||||
const evict = async () => {
|
||||
const retired = await hosts.get(taskA)!.request({ op: "retire", id: "host-eviction", parkId: parkId(releases[0]!) });
|
||||
expect(retired.status).toBe("retired");
|
||||
await handles.get(taskA)!.completion;
|
||||
expect((await state()).lifecycle).toBe("suspended");
|
||||
// The existing task runtime records physical stop and state availability.
|
||||
expect(await dispatch(leaseA, { kind: "status" })).toMatchObject({ executionStopped: mode !== "missing stop", sessionStateAvailable: mode !== "missing state" && mode !== "missing stop" });
|
||||
if (mode !== "missing stop") {
|
||||
await dispatch(leaseA, { kind: "complete" });
|
||||
await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success" }).where(eq(environmentLeases.id, leaseA));
|
||||
}
|
||||
};
|
||||
if (mode !== "reused lease") await evict();
|
||||
const admitNext = async () => {
|
||||
const runB = randomUUID(), leaseB = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({ id: runB, companyId, agentId, nativeIssueId: issueId, status: "running", runtimeMode: "native" });
|
||||
await db.insert(nativeRunFinalizations).values({ runId: runB, companyId, issueId, phase: "observed" });
|
||||
await db.update(issues).set({ executionRunId: runB, status: "in_progress" }).where(eq(issues.id, issueId));
|
||||
const [oldLease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseA));
|
||||
await db.insert(environmentLeases).values({ id: leaseB, companyId, environmentId, heartbeatRunId: runB, issueId, executionWorkspaceId: workspaceId,
|
||||
provider: providerKey, providerLeaseId: mode === "reused lease" ? taskA : taskB, status: "active", leasePolicy: "reuse_by_environment",
|
||||
metadata: mode === "reused lease" ? { ...oldLease!.metadata, runnerTaskReused: true, runnerTaskPreviousLeaseId: leaseA,
|
||||
runnerTaskExecutionStopped: false } : { ...metadata, remoteCwd: workspace } });
|
||||
if (mode === "reused lease") {
|
||||
expect(handles.get(taskA)!.isExitConfirmed?.()).toBe(false);
|
||||
await evict(); // B already holds A when the host evicts it, before normal describe/claim.
|
||||
}
|
||||
const next = { ...execution, binding: { ...execution.binding, runId: runB } };
|
||||
const releasePreparation = mode === "prepared copy" ? await claimWarmNativeInstructionCopy({ priorExecution: execution,
|
||||
companyId, agentId, executionWorkspaceId: workspaceId, workspace: execution.workspace, environmentId, runId: runB,
|
||||
preparationKey: "unchanged-fixture-instructions", adopt: async () => instructionCopy(runB) }) : null;
|
||||
if (mode === "prepared copy") expect(releasePreparation).toBeTypeOf("function");
|
||||
pendingFollowup = execute(next, leaseB);
|
||||
if (denied) {
|
||||
const refusal = mode === "working state" ? "checkpoint_not_settled"
|
||||
: mode === "foreign state" ? "Plugin Runner checkpoint identity mismatch"
|
||||
: "Plugin Runner retained session is not authorized";
|
||||
await expect(pendingFollowup).rejects.toThrow(refusal);
|
||||
if (mode === "missing stop") expect(sourceReadRequests).toBe(0);
|
||||
else expect(sourceReadRequests).toBeGreaterThan(0);
|
||||
if (mode === "foreign state" || mode === "working state") expect(sourceReadResponses).toBeGreaterThan(0);
|
||||
else expect(sourceReadResponses).toBe(0);
|
||||
expect(submissions).toBe(1); expect(claims).toEqual([]);
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runB))).toHaveLength(0);
|
||||
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))).toHaveLength(1);
|
||||
return runB;
|
||||
}
|
||||
expect(await pendingFollowup).toMatchObject({ exitCode: 0, timedOut: false });
|
||||
expect((await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runB)))[0]).toMatchObject({ schemaStatus: "accepted", resultJson: { result: { reportedWorkDisposition: "done" } } });
|
||||
await reconcileNativeFinalizations(db, [runB]);
|
||||
await releasePreparation?.();
|
||||
return runB;
|
||||
};
|
||||
const runB = await admitNext();
|
||||
if (denied) return;
|
||||
await waitForCondition("automatic release B", () => releases.length === 2);
|
||||
expect(await proof()).toMatchObject({ source: releases[1], taskId: taskB });
|
||||
expect((await supervisor()).snapshot.providerSessionId).toBe(providerSessionId);
|
||||
const methods = (await readFile(calls, "utf8")).trim().split(/\r?\n/);
|
||||
expect(submissions).toBe(2);
|
||||
expect(releases[1]!.runId).toBe(runB);
|
||||
expect(claims).toEqual([]);
|
||||
if (harness === "codex") {
|
||||
expect(methods.filter(method => method === "process-start")).toHaveLength(2);
|
||||
expect(methods.filter(method => method === "turn/start")).toHaveLength(2);
|
||||
} else {
|
||||
const journal = methods.map(line => JSON.parse(line));
|
||||
expect(journal.filter(entry => entry.command === "turn.start")).toHaveLength(2);
|
||||
}
|
||||
expect((await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId)))).toHaveLength(1);
|
||||
} catch (error) {
|
||||
const live = await state().catch(() => ({}));
|
||||
console.info("entry-fixture", { releases: releases.length, claims: claims.length, lifecycle: live.lifecycle,
|
||||
lastCommand: live.lastControllerCommandSeq, ackedSource: live.ackedSourceSeq, outbox: live.outbox?.map((e: { envelope?: { payload?: { eventType?: string } } }) => e.envelope?.payload?.eventType), wire: wireEvents.slice(-70) });
|
||||
throw error;
|
||||
} finally {
|
||||
cleaning = true;
|
||||
await pendingFollowup?.catch(() => undefined);
|
||||
try {
|
||||
const cleanup = await Promise.allSettled([closeIdleWarmNativeSessionsForRestart(), ...[...hosts.values()].map(host => host.close())]);
|
||||
for (const socket of sockets) socket.terminate(); websocket.close();
|
||||
await new Promise<void>(done => tls.close(() => done()));
|
||||
const failures = cleanup.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
if (failures.length) throw new AggregateError(failures.map(result => result.reason), "Fixture cleanup failed");
|
||||
} finally {
|
||||
https.globalAgent.destroy(); https.globalAgent = oldAgent; setDefaultCACertificates(oldCAs);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user