mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
feat(runner): add OpenCode and durable runner UX
This commit is contained in:
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user