feat(runner): add OpenCode and durable runner UX

This commit is contained in:
Dotta committed 2026-08-21 15:18:00 -05:00
1 parent f34591016e
commit 4c3e30cdad
100 files changed
+7156 -412

No files matched your search

@@ -280,6 +280,7 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => {
},
workspace: { cwd: repoRoot, repoUrl: null, repoRef: null, branchName: null },
session: { normalizedSessionId: sessionId, driverKind: "codex_app_server", protocolVersion: 1 },
provider: { kind: "codex", model: null },
completionContract: {
id: contractId,
sha256: contractSha,
+13
View File
@@ -8,6 +8,19 @@ import { requireServerAdapter } from "./registry.js";
// obey the same fail-closed contract as an external adapter.
describe("built-in adapter login capabilities", () => {
it("publishes the qualified OpenCode runner configuration", async () => {
const adapter = requireServerAdapter("paperclip_runner");
expect(adapter.models).toContainEqual({
id: "openrouter/deepseek/deepseek-v4-flash-0731",
label: "OpenRouter · DeepSeek V4 Flash 0731",
});
expect(adapter.getRuntimeCommandSpec?.({ provider: "opencode" }))
.toMatchObject({ command: "opencode", installCommand: expect.stringContaining("opencode-ai@1.18.17") });
const schema = await adapter.getConfigSchema?.();
expect(schema?.fields.map((field) => field.key))
.toEqual(expect.arrayContaining(["provider", "model", "command"]));
});
it("registers the Codex device-login capability", () => {
const capability = requireServerAdapter("codex_local").loginCapability;
expect(capability).toBeDefined();
+107 -7
View File
@@ -3,6 +3,8 @@ import type {
AdapterRuntimeCommandSpec,
ServerAdapterModule,
} from "./types.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
import {
@@ -133,6 +135,68 @@ import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
const execFileAsync = promisify(execFile);
async function testPaperclipRunnerOpenCodeEnvironment(
context: Parameters<typeof openCodeTestEnvironment>[0],
) {
const result = await openCodeTestEnvironment(context);
if (context.executionTarget?.kind === "remote") {
return {
...result,
status: result.status === "pass" ? "warn" as const : result.status,
checks: [...result.checks, {
code: "opencode_version_unverified_remote",
level: "warn" as const,
message: "OpenCode version could not be qualified by the local runner probe on a remote target.",
}],
};
}
const command = readConfiguredCommand(context.config, "opencode");
try {
const { stdout, stderr } = await execFileAsync(command, ["--version"], {
timeout: 5_000,
env: process.env,
});
const output = `${stdout}\n${stderr}`;
const match = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
if (!match) throw new Error("OpenCode returned an unrecognized version string");
const version = `${match[1]}.${match[2]}.${match[3]}`;
const comparison = compareSemver(version, "1.18.17");
const check = comparison < 0
? { code: "opencode_version_too_old", level: "error" as const, message: `OpenCode ${version} is older than required 1.18.17.` }
: comparison > 0
? { code: "opencode_version_unqualified", level: "warn" as const, message: `OpenCode ${version} is newer than qualified version 1.18.17.` }
: { code: "opencode_version_qualified", level: "info" as const, message: "OpenCode 1.18.17 is installed and qualified." };
return {
...result,
status: check.level === "error" ? "fail" as const
: check.level === "warn" && result.status === "pass" ? "warn" as const
: result.status,
checks: [...result.checks, check],
};
} catch (error) {
return {
...result,
status: "fail" as const,
checks: [...result.checks, {
code: "opencode_version_probe_failed",
level: "error" as const,
message: error instanceof Error ? error.message : "OpenCode version probe failed.",
}],
};
}
}
function compareSemver(left: string, right: string): number {
const a = left.split(".").map(Number);
const b = right.split(".").map(Number);
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0);
}
return 0;
}
function readConfiguredCommand(config: Record<string, unknown>, fallback: string): string {
const value = typeof config.command === "string" ? config.command.trim() : "";
return value.length > 0 ? value : fallback;
@@ -345,13 +409,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
};
},
async testEnvironment(context) {
const result = await codexTestEnvironment(context);
const provider = context.config.provider === "opencode" ? "opencode" : "codex";
const result = provider === "opencode"
? await testPaperclipRunnerOpenCodeEnvironment(context)
: await codexTestEnvironment(context);
return { ...result, adapterType: "paperclip_runner" };
},
listSkills: listCodexSkills,
syncSkills: syncCodexSkills,
sessionCodec: codexSessionCodec,
models: codexModels,
models: [
...codexModels,
{ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "OpenRouter · DeepSeek V4 Flash 0731" },
],
modelProfiles: codexModelProfiles,
listModels: listCodexModels,
refreshModels: refreshCodexModels,
@@ -359,17 +429,47 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) =>
buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex through the Rust Paperclip runner and authenticated PRP control-plane connection.\n`,
getRuntimeCommandSpec: (config) => config.provider === "opencode"
? buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai@1.18.17")
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex or OpenCode through the native Paperclip runner and authenticated PRP control-plane connection. OpenCode requires a provider/model identifier and runtime OPENROUTER_API_KEY.\n`,
getConfigSchema: () => ({
fields: [{
key: "provider",
label: "Provider",
type: "select",
default: "codex",
options: [{ value: "codex", label: "Codex" }],
hint: "Paperclip Runner currently supports the Codex app-server provider.",
options: [{ value: "codex", label: "Codex" }, { value: "opencode", label: "OpenCode 1.18.17" }],
hint: "Select the native provider. OpenCode runs through its authenticated loopback server.",
}, {
key: "lifecycleMode",
label: "Runner lifecycle",
type: "select",
default: "per_turn",
options: [
{ value: "per_turn", label: "Turn by turn" },
{ value: "warm", label: "Warm session" },
],
hint: "Warm sessions retain runnerd and the provider between separately governed runs.",
}, {
key: "idleTimeoutMs",
label: "Warm idle timeout (ms)",
type: "number",
default: 300000,
hint: "Warm sessions suspend resumably after this much inactivity.",
}, {
key: "model",
label: "OpenCode model",
type: "text",
default: "openrouter/deepseek/deepseek-v4-flash-0731",
placeholder: "openrouter/deepseek/deepseek-v4-flash-0731",
hint: "Required when provider is OpenCode; use provider/model form.",
}, {
key: "command",
label: "OpenCode command",
type: "text",
default: "opencode",
hint: "OpenCode executable. Version 1.18.17 is qualified.",
}],
}),
loginCapability: codexLoginCapability,
+14 -1
View File
@@ -15941,6 +15941,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
branchName: executionWorkspace.branchName,
},
normalizedSessionId: nativeSessionId,
provider: nativeRuntimeResolution.profile.backend === "opencode_server" ? "opencode" : "codex",
model: typeof parseObject(agent.adapterConfig).model === "string"
? String(parseObject(agent.adapterConfig).model)
: null,
lifecyclePolicy: parseObject(agent.adapterConfig).lifecycleMode === "warm"
? {
mode: "warm",
idleTimeoutMs: Number.isSafeInteger(parseObject(agent.adapterConfig).idleTimeoutMs)
&& Number(parseObject(agent.adapterConfig).idleTimeoutMs) > 0
? Number(parseObject(agent.adapterConfig).idleTimeoutMs)
: 300_000,
}
: { mode: "per_turn", idleTimeoutMs: null },
interactionResponses,
completionContract: {
id: completionContract.row.id,
@@ -15970,7 +15983,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
},
runnerInstanceId: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
nativeSessionId: lockedRun.nativeSessionId ?? nativeSessionId,
driverKind: lockedRun.driverKind ?? "codex_app_server",
driverKind: lockedRun.driverKind ?? nativeExecution?.session.driverKind ?? "codex_app_server",
driverVersion: lockedRun.driverVersion ?? "phase6-v1",
completionContractId: lockedRun.completionContractId ?? completionContract.row.id,
completionContractSha256: lockedRun.completionContractSha256 ?? completionContract.row.canonicalSha256,
@@ -26,6 +26,9 @@ export function buildNativeExecutionInput(input: {
branchName: string | null;
};
normalizedSessionId: string | null;
provider?: "codex" | "opencode";
model?: string | null;
lifecyclePolicy?: NativeExecutionInputV1["session"]["lifecyclePolicy"];
interactionResponses?: NativeInteractionResponseEnvelope[];
completionContract: {
id: string;
@@ -61,8 +64,13 @@ export function buildNativeExecutionInput(input: {
},
session: {
normalizedSessionId: input.normalizedSessionId,
driverKind: "codex_app_server",
driverKind: input.provider === "opencode" ? "opencode_server" : "codex_app_server",
protocolVersion: 1,
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
},
provider: {
kind: input.provider ?? "codex",
model: input.model ?? null,
},
completionContract: input.completionContract,
interactionResponses: input.interactionResponses ?? [],
@@ -23,7 +23,7 @@ const state = vi.hoisted(() => ({
}));
vi.mock("@paperclipai/paperclip-runner", () => ({
createCodexNativeSessionBackend: state.createBackend,
createNativeSessionBackend: state.createBackend,
executeNativeSession: state.execute,
}));
@@ -31,9 +31,11 @@ import {
cancelNativeSession,
executePaperclipNativeSession,
nativeSessionFailureDisposition,
nativeSessionFailureSourceCode,
} from "./native-session-executor.js";
const execution = {
provider: { kind: "codex", model: null },
binding: {
companyId: "company",
runId: "run-native-cancel",
@@ -43,11 +45,11 @@ const execution = {
completionContract: { id: "contract", sha256: "sha" },
} as NativeExecutionInputV1;
function leaseDb(): Db {
function leaseDb(boundExecution: NativeExecutionInputV1 = execution): Db {
const coordinator = {
runId: execution.binding.runId,
companyId: execution.binding.companyId,
issueId: execution.binding.issueId,
runId: boundExecution.binding.runId,
companyId: boundExecution.binding.companyId,
issueId: boundExecution.binding.issueId,
phase: "observed",
attempt: 0,
leaseOwner: null,
@@ -135,7 +137,73 @@ describe("native session cancellation", () => {
});
});
describe("native warm session supervision", () => {
it("reuses one session across distinct governed runs and closes it after idle expiry", async () => {
const close = vi.fn(async () => undefined);
const sharedSession = { close };
const base = {
...execution,
binding: {
...execution.binding,
executionWorkspaceId: "workspace",
},
workspace: { cwd: "/tmp/warm-native", repoUrl: null, repoRef: null, branchName: null },
session: {
normalizedSessionId: "session-warm-native",
driverKind: "codex_app_server" as const,
protocolVersion: 1 as const,
lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 20 },
},
} as NativeExecutionInputV1;
const second = {
...base,
binding: { ...base.binding, runId: "run-native-warm-second" },
};
const result = {
result: { summary: "completed" },
terminal: { runTerminalState: "succeeded" },
turnId: "turn",
normalizedSessionId: "session-warm-native",
providerSessionId: "provider-warm-native",
driverKind: "test",
driverVersion: "1",
nativeEventCount: 1,
highestContiguousSourceSeq: 1,
usage: null,
};
state.execute.mockReset()
.mockImplementationOnce(async (options) => {
expect(options.existingSession).toBeUndefined();
options.onSession?.(sharedSession);
return result;
})
.mockImplementationOnce(async (options) => {
expect(options.existingSession).toBe(sharedSession);
return result;
});
await executePaperclipNativeSession({ db: leaseDb(base), execution: base, runnerInstanceId: "runner" });
await executePaperclipNativeSession({ db: leaseDb(second), execution: second, runnerInstanceId: "runner" });
expect(close).not.toHaveBeenCalled();
await vi.waitFor(() => expect(close).toHaveBeenCalledWith({
reason: "warm native session idle timeout",
}), { timeout: 500 });
});
});
describe("native session bounded recovery", () => {
it("preserves stable provider and runner failure causes", () => {
expect(nativeSessionFailureSourceCode(new Error(
"provider_frame_too_large: harness stdout frame exceeded 4194304 bytes",
))).toBe("provider_frame_too_large");
expect(nativeSessionFailureSourceCode(new Error(
"native_runner_process_exited: runnerd exited unexpectedly with code 1",
))).toBe("native_runner_process_exited");
expect(nativeSessionFailureSourceCode(new Error(
"provider_transport_failed: invalid JSON-RPC",
))).toBe("provider_transport_failed");
});
it("retries the same run twice and stops at the third failed attempt", () => {
const now = new Date("2026-08-09T00:00:00.000Z");
expect(nativeSessionFailureDisposition(1, now)).toEqual({
@@ -191,10 +259,10 @@ describe("native process ownership", () => {
onSpawn,
});
expect(state.createBackend).toHaveBeenCalledWith(execution, {
expect(state.createBackend).toHaveBeenCalledWith(execution, expect.objectContaining({
runnerInstanceId: "runner",
onSpawn,
});
}));
expect(onSpawn).toHaveBeenCalledWith(processMetadata);
});
});
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import type { AdapterExecutionResult } from "../../adapters/index.js";
@@ -8,9 +8,10 @@ import type {
NativeExecutionInputV1,
NativeSession,
NativeSessionBackend,
PersistedNativeSession,
} from "../../vendor/paperclip-runner/index.js";
import {
createCodexNativeSessionBackend,
createNativeSessionBackend,
createRunnerdCodexTransport,
executeNativeSession,
} from "../../vendor/paperclip-runner/index.js";
@@ -23,6 +24,7 @@ import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { persistActivity, publishActivity } from "../activity-log.js";
import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
import {
NATIVE_STATUS_ARBITER_POLICY_VERSION,
type NativeAuthoritativeIssueStatus,
@@ -36,6 +38,134 @@ type ActiveNativeSession = {
const activeNativeSessions = new Map<string, ActiveNativeSession>();
type WarmNativeSession = {
session: NativeSession;
configDigest: string;
busy: boolean;
idleTimer: ReturnType<typeof setTimeout> | null;
lastActivityAt: string;
};
const warmNativeSessions = new Map<string, WarmNativeSession>();
class SessionToolAuthorityRouter {
#authority: PaperclipRunnerToolAuthority;
constructor(authority: PaperclipRunnerToolAuthority) {
this.#authority = authority;
}
bind(authority: PaperclipRunnerToolAuthority): void {
this.#authority = authority;
}
definitions() {
return this.#authority.definitions();
}
execute(call: Parameters<PaperclipRunnerToolAuthority["execute"]>[0]) {
return this.#authority.execute(call);
}
}
const sessionToolRouters = new Map<string, SessionToolAuthorityRouter>();
function nativeSessionKey(execution: NativeExecutionInputV1): string {
return execution.session.normalizedSessionId ?? `session-${execution.binding.runId}`;
}
function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
return `sha256:${createHash("sha256").update(JSON.stringify({
companyId: execution.binding.companyId,
issueId: execution.binding.issueId,
agentId: execution.binding.agentId,
workspaceId: execution.binding.executionWorkspaceId,
cwd: execution.workspace.cwd,
provider: execution.provider,
driverKind: execution.session.driverKind,
lifecyclePolicy: execution.session.lifecyclePolicy,
})).digest("hex")}`;
}
function nativeSessionCheckpointPath(sessionId: string): string {
const directory = resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "sessions");
mkdirSync(directory, { recursive: true, mode: 0o700 });
chmodSync(directory, 0o700);
return resolve(directory, `${createHash("sha256").update(sessionId).digest("hex")}.json`);
}
function persistWarmNativeCheckpoint(
sessionId: string,
configDigest: string,
snapshot: PersistedNativeSession,
): void {
const path = nativeSessionCheckpointPath(sessionId);
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
writeFileSync(temporary, JSON.stringify({
schema: "paperclip.native-session-supervisor.v1",
configDigest,
updatedAt: new Date().toISOString(),
snapshot,
}), { encoding: "utf8", mode: 0o600 });
renameSync(temporary, path);
chmodSync(path, 0o600);
}
function loadWarmNativeCheckpoint(
execution: NativeExecutionInputV1,
configDigest: string,
): PersistedNativeSession | null {
const path = nativeSessionCheckpointPath(nativeSessionKey(execution));
if (!existsSync(path)) return null;
const envelope = JSON.parse(readFileSync(path, "utf8")) as {
schema?: string;
configDigest?: string;
snapshot?: PersistedNativeSession;
};
if (envelope.schema !== "paperclip.native-session-supervisor.v1" || envelope.configDigest !== configDigest || !envelope.snapshot) {
throw new Error("native_session_supervisor_checkpoint_mismatch");
}
return {
...envelope.snapshot,
identity: {
runId: execution.binding.runId,
sessionId: nativeSessionKey(execution),
companyId: execution.binding.companyId,
issueId: execution.binding.issueId,
agentId: execution.binding.agentId,
},
semanticResult: null,
terminal: null,
activeTurnId: null,
terminalTurns: [],
pendingRuntimeRequests: [],
};
}
async function releaseWarmNativeSession(
sessionId: string,
idleTimeoutMs: number,
failed: boolean,
): Promise<void> {
const entry = warmNativeSessions.get(sessionId);
if (!entry) return;
entry.busy = false;
entry.lastActivityAt = new Date().toISOString();
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
if (failed) {
warmNativeSessions.delete(sessionId);
await entry.session.close({ reason: "warm native session failed" }).catch(() => undefined);
return;
}
entry.idleTimer = setTimeout(() => {
const current = warmNativeSessions.get(sessionId);
if (!current || current.busy) return;
warmNativeSessions.delete(sessionId);
void current.session.close({ reason: "warm native session idle timeout" });
}, idleTimeoutMs);
entry.idleTimer.unref();
}
export function nativeSessionFailureDisposition(attempt: number, now = new Date()) {
const exhausted = attempt >= 3;
return {
@@ -45,6 +175,24 @@ export function nativeSessionFailureDisposition(attempt: number, now = new Date(
};
}
export function nativeSessionFailureSourceCode(error: unknown):
| "provider_frame_too_large"
| "provider_transport_failed"
| "native_runner_process_exited"
| "native_session_interrupted" {
const message = error instanceof Error ? error.message : String(error);
if (/provider_frame_too_large|stdout frame exceeded/i.test(message)) {
return "provider_frame_too_large";
}
if (/provider_transport_failed|invalid JSON-RPC|provider failed/i.test(message)) {
return "provider_transport_failed";
}
if (/native_runner_process_exited|runnerd exited|runner process failed/i.test(message)) {
return "native_runner_process_exited";
}
return "native_session_interrupted";
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
@@ -301,6 +449,25 @@ export async function executePaperclipNativeSession(input: {
: undefined,
});
let native: Awaited<ReturnType<typeof executeNativeSession>>;
const lifecyclePolicy = input.execution.session?.lifecyclePolicy
?? { mode: "per_turn" as const, idleTimeoutMs: null };
const warmSessionId = lifecyclePolicy.mode === "warm" ? nativeSessionKey(input.execution) : null;
const warmConfigDigest = lifecyclePolicy.mode === "warm" ? nativeSessionConfigDigest(input.execution) : null;
let existingWarmSession: NativeSession | undefined;
let persistedWarmSession: PersistedNativeSession | null | undefined;
if (warmSessionId !== null && warmConfigDigest !== null) {
const entry = warmNativeSessions.get(warmSessionId);
if (entry) {
if (entry.configDigest !== warmConfigDigest) throw new Error("native_session_supervisor_config_mismatch");
if (entry.busy) throw new Error("native_session_supervisor_busy");
entry.busy = true;
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
entry.idleTimer = null;
existingWarmSession = entry.session;
} else {
persistedWarmSession = loadWarmNativeCheckpoint(input.execution, warmConfigDigest);
}
}
try {
const runnerdBackend = input.useRunnerd && input.backend === undefined
? createRunnerdBackend(input)
@@ -309,14 +476,33 @@ export async function executePaperclipNativeSession(input: {
input: input.execution,
backend: input.backend
?? runnerdBackend
?? createCodexNativeSessionBackend(input.execution, {
?? createNativeSessionBackend(input.execution, {
runnerInstanceId: input.runnerInstanceId,
onSpawn: input.onSpawn,
environment: process.env,
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
}),
controlPlane,
runnerInstanceId: input.runnerInstanceId,
controlPlaneInstanceId,
existingSession: existingWarmSession,
persistedSession: persistedWarmSession,
keepSessionOpen: warmSessionId !== null,
onCheckpoint: warmSessionId !== null && warmConfigDigest !== null
? async (snapshot) => persistWarmNativeCheckpoint(warmSessionId, warmConfigDigest, snapshot)
: undefined,
onSession: (session) => {
if (session && warmSessionId !== null && warmConfigDigest !== null) {
const existing = warmNativeSessions.get(warmSessionId);
if (existing) existing.session = session;
else warmNativeSessions.set(warmSessionId, {
session,
configDigest: warmConfigDigest,
busy: true,
idleTimer: null,
lastActivityAt: new Date().toISOString(),
});
}
if (session) activeNativeSessions.set(input.execution.binding.runId, {
session,
cancelRequested: false,
@@ -324,11 +510,17 @@ export async function executePaperclipNativeSession(input: {
else activeNativeSessions.delete(input.execution.binding.runId);
},
});
activeNativeSessions.delete(input.execution.binding.runId);
} catch (error) {
activeNativeSessions.delete(input.execution.binding.runId);
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
await releaseWarmNativeSession(warmSessionId, lifecyclePolicy.idleTimeoutMs, true);
}
const now = new Date();
const { phase, failureCode, nextAttemptAt } = nativeSessionFailureDisposition(attempt, now);
const exhausted = phase === "terminal_failure";
const message = error instanceof Error ? error.message.slice(0, 2_000) : String(error).slice(0, 2_000);
const sourceFailureCode = nativeSessionFailureSourceCode(error);
await input.db.transaction(async (tx) => {
const updated = await tx.update(nativeRunFinalizations).set({
phase,
@@ -337,7 +529,7 @@ export async function executePaperclipNativeSession(input: {
failureCode,
failureDetail: {
message,
originalFailureCode: "native_session_interrupted",
originalFailureCode: sourceFailureCode,
recoveryOwner: { kind: "agent", agentId: input.execution.binding.agentId },
nextAction: exhausted
? "Inspect the persisted native session after its bounded resume budget was exhausted."
@@ -354,7 +546,7 @@ export async function executePaperclipNativeSession(input: {
nativePhase: phase,
nativePhaseUpdatedAt: now,
error: message,
errorCode: failureCode,
errorCode: sourceFailureCode,
updatedAt: now,
}).where(eq(heartbeatRuns.id, input.execution.binding.runId));
await issueRecoveryActionService(tx as unknown as Db).upsertSourceScoped({
@@ -363,11 +555,16 @@ export async function executePaperclipNativeSession(input: {
kind: "active_run_watchdog",
ownerType: "agent",
ownerAgentId: input.execution.binding.agentId,
cause: failureCode,
cause: sourceFailureCode,
fingerprint: createHash("sha256")
.update(`${input.execution.binding.runId}:${failureCode}`)
.update(`${input.execution.binding.runId}:${sourceFailureCode}`)
.digest("hex"),
evidence: { runId: input.execution.binding.runId, coordinatorAttempt: attempt },
evidence: {
runId: input.execution.binding.runId,
coordinatorAttempt: attempt,
sourceFailureCode,
recoveryDisposition: failureCode,
},
nextAction: exhausted
? "Inspect or explicitly restart the exhausted persisted native session."
: "Resume the persisted native session on the same heartbeat run.",
@@ -405,6 +602,11 @@ export async function executePaperclipNativeSession(input: {
highestContiguousSourceSeq: native.highestContiguousSourceSeq,
workspaceFinalizeStatus: "pending",
};
// A following run cannot attach until the prior run's durable finalization
// is committed. Provider completion alone is not an authority boundary.
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
await releaseWarmNativeSession(warmSessionId, lifecyclePolicy.idleTimeoutMs, false);
}
return {
exitCode: native.terminal.runTerminalState === "succeeded" ? 0 : 1,
signal: null,
@@ -419,12 +621,41 @@ export async function executePaperclipNativeSession(input: {
summary: native.result.summary,
sessionId: native.normalizedSessionId,
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
provider: "openai",
provider: input.execution.provider.kind === "opencode"
? input.execution.provider.model?.split("/", 1)[0] ?? "opencode"
: "openai",
model: input.execution.provider.model,
usage: normalizeNativeUsage(native.usage),
costUsd: numericUsageField(native.usage, ["costUsd", "cost"]),
usageBasis: "per_run",
nativeFinalization: finalization,
};
}
function numericUsageField(
usage: Record<string, unknown> | null,
keys: string[],
): number | undefined {
if (!usage) return undefined;
for (const key of keys) {
const value = usage[key];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
}
return undefined;
}
function normalizeNativeUsage(usage: Record<string, unknown> | null) {
if (!usage) return undefined;
const cache = record(usage.cache);
const cachedInputTokens = numericUsageField(usage, ["cachedInputTokens", "cacheReadInputTokens"])
?? numericUsageField(cache, ["read"]);
return {
inputTokens: numericUsageField(usage, ["inputTokens", "input", "promptTokens"]) ?? 0,
outputTokens: numericUsageField(usage, ["outputTokens", "output", "completionTokens"]) ?? 0,
...(cachedInputTokens === undefined ? {} : { cachedInputTokens }),
};
}
function createRunnerdBackend(input: {
db: Db;
execution: NativeExecutionInputV1;
@@ -437,19 +668,28 @@ function createRunnerdBackend(input: {
runId: input.execution.binding.runId,
agentId: input.execution.binding.agentId,
});
const sessionId = nativeSessionKey(input.execution);
const existingRouter = sessionToolRouters.get(sessionId);
const authorityRouter = existingRouter ?? new SessionToolAuthorityRouter(authority);
authorityRouter.bind(authority);
sessionToolRouters.set(sessionId, authorityRouter);
const root = resolve(
process.env.PAPERCLIP_RUNNER_STATE_DIR ?? resolve(tmpdir(), "paperclip-runner"),
input.execution.binding.runId,
createHash("sha256").update(sessionId).digest("hex"),
);
mkdirSync(root, { recursive: true, mode: 0o700 });
return createCodexNativeSessionBackend(input.execution, {
return createNativeSessionBackend(input.execution, {
runnerInstanceId: input.runnerInstanceId,
onSpawn: input.onSpawn,
dynamicTools: authority.definitions(),
dynamicToolHandler: (call) => authority.execute(call),
transportFactory: () => createRunnerdCodexTransport({
dynamicTools: authorityRouter.definitions(),
dynamicToolHandler: (call) => authorityRouter.execute(call),
environment: process.env,
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
codexTransportFactory: () => createRunnerdCodexTransport({
stateDirectory: root,
environment: process.env,
lifecyclePolicy: input.execution.session.lifecyclePolicy,
resumeDynamicTools: authorityRouter.definitions(),
prpIdentity: {
runnerInstanceId: input.runnerInstanceId,
environmentLeaseId: input.execution.binding.executionWorkspaceId,
@@ -35,7 +35,24 @@ describe("resolveNativeRuntimeMode", () => {
runtimeConfig: {},
adapterConfig: { provider: "claude" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toThrow(/provider must be codex/);
})).toThrow(/provider must be codex or opencode/);
});
it("selects OpenCode only with a provider/model value", () => {
expect(resolveNativeRuntimeMode({
...eligible,
runtimeConfig: {},
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toEqual(expect.objectContaining({
profile: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
}));
expect(() => resolveNativeRuntimeMode({
...eligible,
runtimeConfig: {},
adapterConfig: { provider: "opencode", model: "deepseek" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toThrow(/provider\/model/);
});
it("preserves legacy as the default and as the kill-switch behavior", () => {
@@ -74,6 +91,22 @@ describe("resolveNativeRuntimeMode", () => {
expect(disabled.runtimeConfig.nativeRunner.mode).toBe("native");
});
it("keeps a persisted OpenCode recovery on its immutable driver", () => {
expect(resolveHeartbeatNativeRuntimeMode({
...eligible,
enabled: false,
adapterConfig: { provider: "codex" },
persisted: {
runtimeMode: "native",
runtimeModeReason: "eligible_opt_in",
runtimeModeResolvedAt: new Date(),
driverKind: "opencode_server",
},
})).toEqual(expect.objectContaining({
profile: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
}));
});
it("rejects an explicit native profile outside the approved boundary", () => {
expect(() => resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } }))
.toThrow(NativeRuntimeEligibilityError);
@@ -17,7 +17,7 @@ export type NativeRuntimeResolution =
kind: "native";
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
reason: "eligible_opt_in";
profile: { mode: "native"; backend: "codex_app_server"; protocolVersion: 1 };
profile: { mode: "native"; backend: "codex_app_server" | "opencode_server"; protocolVersion: 1 };
authorityDecision: NativeStatusDecision;
};
@@ -47,8 +47,14 @@ export function resolveNativeRuntimeMode(input: {
const nativeRunner = record(record(input.runtimeConfig).nativeRunner);
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
const runnerProvider = record(input.adapterConfig).provider ?? "codex";
if (runnerAdapterSelected && runnerProvider !== "codex") {
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex");
if (runnerAdapterSelected && runnerProvider !== "codex" && runnerProvider !== "opencode") {
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex or opencode");
}
if (runnerAdapterSelected && runnerProvider === "opencode") {
const model = record(input.adapterConfig).model;
if (typeof model !== "string" || !model.includes("/") || model.trim().endsWith("/")) {
throw new NativeRuntimeEligibilityError("paperclip_runner OpenCode provider requires model in provider/model form");
}
}
const mode = runnerAdapterSelected ? "native" : nativeRunner.mode;
if (!runnerAdapterSelected && !input.enabled) {
@@ -101,7 +107,11 @@ export function resolveNativeRuntimeMode(input: {
kind: "native",
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
reason: "eligible_opt_in",
profile: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
profile: {
mode: "native",
backend: runnerProvider === "opencode" ? "opencode_server" : "codex_app_server",
protocolVersion: 1,
},
authorityDecision: rollout,
};
}
@@ -116,6 +126,7 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
runtimeMode: string | null;
runtimeModeReason: string | null;
runtimeModeResolvedAt: Date | null;
driverKind?: string | null;
};
enabled: boolean;
runtimeConfig: unknown;
@@ -131,7 +142,11 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
kind: "native",
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
reason: "eligible_opt_in",
profile: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
profile: {
mode: "native",
backend: input.persisted.driverKind === "opencode_server" ? "opencode_server" : "codex_app_server",
protocolVersion: 1,
},
authorityDecision: resolveNativeMigrationStatus({
facts: input.enabled
? { applicationEnabled: true }